PSChromeDevToolsServer.psm1

class CdpFrame {
    [string]$FrameId
    [string]$ParentFrameId

    hidden [System.Threading.ManualResetEventSlim]$RuntimeReady = [System.Threading.ManualResetEventSlim]::new($false)

    CdpFrame ($FrameId, $SessionId) {
        $this.LoadingState['NetworkIdle'] = [System.Threading.ManualResetEventSlim]::new($false)
        $this.LoadingState['FrameStoppedLoading'] = [System.Threading.ManualResetEventSlim]::new($false)
        $this.LoadingState['Load'] = [System.Threading.ManualResetEventSlim]::new($false)
        $this.LoadingState['FirstPaint'] = [System.Threading.ManualResetEventSlim]::new($false)

        $this.FrameId = $FrameId
        $this.ParentFrameId = $null
        $this.TargetInfo['SessionId'] = $SessionId
        $this.TargetInfo['Url'] = $null
        $this.PageInfo['RuntimeUniqueId'] = $null
    }

    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$TargetInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$PageInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, [System.Threading.ManualResetEventSlim]]]$LoadingState = [System.Collections.Concurrent.ConcurrentDictionary[string, [System.Threading.ManualResetEventSlim]]]::new()

    [void]ResetLoadingState() {
        $this.LoadingState['NetworkIdle'].Reset()
        $this.LoadingState['FrameStoppedLoading'].Reset()
        $this.LoadingState['Load'].Reset()
        $this.LoadingState['FirstPaint'].Reset()
    }

    [void]Dispose() {
        $this.RuntimeReady.Set()
        $this.RuntimeReady.Dispose()
        foreach ($LoadingState in $this.LoadingState.GetEnumerator()) {
            $LoadingState.Value.Set()
            $LoadingState.Value.Dispose()
        }
    }
}

class CdpPage {
    # it's more dictionary now than property
    # did not want to use monitor.enter/exit
    [string]$TargetId
    [string]$Title
    [int]$ProcessId
    [object]$CdpServer

    hidden [System.Threading.ManualResetEventSlim]$SessionReady = [System.Threading.ManualResetEventSlim]::new($false)
    hidden [System.Threading.ManualResetEventSlim]$RuntimeReady = [System.Threading.ManualResetEventSlim]::new($false)

    CdpPage($TargetId, $Url, $Title, $BrowserContextId, $CdpServer) {
        $this.LoadingState['NetworkIdle'] = [System.Threading.ManualResetEventSlim]::new($false)
        $this.LoadingState['FrameStoppedLoading'] = [System.Threading.ManualResetEventSlim]::new($false)
        $this.LoadingState['Load'] = [System.Threading.ManualResetEventSlim]::new($false)
        $this.LoadingState['FirstPaint'] = [System.Threading.ManualResetEventSlim]::new($false)

        $this.TargetId = $TargetId
        $this.Title = $Title
        $this.CdpServer = $CdpServer

        $this.TargetInfo['SessionId'] = $null
        $this.TargetInfo['Url'] = $Url
        $this.TargetInfo['BrowserContextId'] = $BrowserContextId

        $this.PageInfo['RuntimeUniqueId'] = $null
        $this.PageInfo['ObjectId'] = $null
        $this.PageInfo['Node'] = $null
        $this.PageInfo['BoxModel'] = $null
    }

    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$TargetInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$PageInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, [System.Threading.ManualResetEventSlim]]]$LoadingState = [System.Collections.Concurrent.ConcurrentDictionary[string, [System.Threading.ManualResetEventSlim]]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, CdpFrame]]$Frames = [System.Collections.Concurrent.ConcurrentDictionary[string, CdpFrame]]::new()


    [void]ResetLoadingState() {
        $this.LoadingState['NetworkIdle'].Reset()
        $this.LoadingState['FrameStoppedLoading'].Reset()
        $this.LoadingState['Load'].Reset()
        $this.LoadingState['FirstPaint'].Reset()
    }

    [void]Dispose() {
        $this.SessionReady.Set()
        $this.RuntimeReady.Set()
        $this.SessionReady.Dispose()
        $this.RuntimeReady.Dispose()
        foreach ($LoadingState in $this.LoadingState.GetEnumerator()) {
            $LoadingState.Value.Set()
            $LoadingState.Value.Dispose()
        }

        foreach ($CdpFrame in $this.Frames.GetEnumerator()) {
            $CdpFrame.Dispose()
        }
    }
}

# [NoRunspaceAffinity()]
class CdpEventHandler {
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$SharedState
    [System.Collections.Concurrent.ConcurrentDictionary[string, [System.Threading.ManualResetEventSlim]]]$NewTargets = [System.Collections.Concurrent.ConcurrentDictionary[string, [System.Threading.ManualResetEventSlim]]]::new()
    [hashtable]$EventHandlers

    CdpEventHandler([System.Collections.Concurrent.ConcurrentDictionary[string, object]]$SharedState) {
        $this.SharedState = $SharedState
        $this.InitializeHandlers()
    }

    hidden [void]InitializeHandlers() {
        $this.EventHandlers = @{
            'Page.frameAttached' = $this.FrameAttached
            'Page.frameDetached' = $this.FrameDetached
            'Page.frameNavigated' = $this.FrameNavigated
            'Page.lifecycleEvent' = $this.LifecycleEvent
            'Page.frameStoppedLoading' = $this.FrameStoppedLoading
            'Target.targetCreated' = $this.TargetCreated
            'Target.targetDestroyed' = $this.TargetDestroyed
            'Target.targetInfoChanged' = $this.TargetInfoChanged
            'Target.attachedToTarget' = $this.AttachedToTarget
            'Target.detachedFromTarget' = $this.DetachedFromTarget
            'Runtime.executionContextsCleared' = $this.ExecutionContextsCleared
            'Runtime.executionContextCreated' = $this.ExecutionContextCreated
        }
    }

    [void]ProcessEvent($Response) {
        $Handler = $this.EventHandlers[$Response.method]
        if ($Handler) {
            $Handler.Invoke($Response)
        }

        $Callback = $this.SharedState.Callbacks["On$($Response.method.Split('.')[1])".ToUpper()]
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameAttached($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $Frame = $CdpPage.Frames.GetOrAdd($Response.params.frameId, [CdpFrame]::new($Response.params.frameId, $Response.sessionId))
        $Frame.ParentFrameId = $Response.params.parentFrameId
    }

    hidden [void]FrameDetached($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $CdpFrame = $null
        if ($CdpPage.Frames.TryRemove($Response.params.frameId, [ref]$CdpFrame)) {
            $CdpFrame.Dispose()
        }
    }

    hidden [void]FrameNavigated($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)

        if ($CdpPage.TargetId -eq $Response.params.frame.id) {
            # Let targetinfo changed update instead.
            # But we have to check incase we're not adding a target into the frame dictionary
        } else {
            $Target = $CdpPage.Frames.GetOrAdd($Response.params.frame.id, [CdpFrame]::new($Response.params.frame.id, $Response.sessionId))
            $Target.TargetInfo['Url'] = $Response.params.frame.url
            $Target.TargetInfo['Name'] = $Response.params.frame.name
        }
    }

    hidden [void]LifecycleEvent($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)

        $Target = if ($CdpPage.TargetId -eq $Response.params.frameId) {
            $CdpPage
        } else {
            $CdpPage.Frames.GetOrAdd($Response.params.frameId, [CdpFrame]::new($Response.params.frameId, $Response.sessionId))
        }

        $LifeCycleName = $Response.params.name

        switch ($LifeCycleName) {
            'init' {
                $Target.LoadingState['NetworkIdle'].Reset()
                break
            }
            'networkIdle' {
                $Target.LoadingState['NetworkIdle'].Set()
                break
            }
            'load' {
                $Target.LoadingState['Load'].Set()
                break
            }
            'firstPaint' {
                $Target.LoadingState['FirstPaint'].Set()
                break
            }
        }
    }

    hidden [void]FrameStoppedLoading($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage.TargetId -eq $Response.params.frameId) {
            $CdpPage.LoadingState['FrameStoppedLoading'].Set()
        } else {
            $Frame = $null
            $null = $CdpPage.Frames.TryGetValue($Response.params.frameId, [ref]$Frame)
            if ($Frame) {
                $Frame.LoadingState['FrameStoppedLoading'].Set()
            }
        }
    }

    hidden [void]TargetCreated($Response) {
        $Target = $Response.params.targetInfo
        $CdpPage = [CdpPage]::new($Target.targetId, $Target.Url, $Target.Title, $Target.browserContextId, $this.SharedState.CdpServer)
        $CdpPageReady = $this.NewTargets.GetOrAdd($Target.targetId, [System.Threading.ManualResetEventSlim]::new($false))
        $this.SharedState.Targets[$Target.targetId] = $CdpPage
        $CdpPageReady.Set()
    }

    hidden [void]TargetDestroyed($Response) {
        $TargetId = $Response.params.targetId
        $CdpPage = $this.GetPageByTargetId($TargetId)
        if ($CdpPage) {
            $null = $this.SharedState.Targets.TryRemove($TargetId, [ref]$null)
            $CdpPage.Dispose()
        }
        $this.NewTargets[$TargetId].Dispose()
        $this.NewTargets[$TargetId] = $null # Don't remove. Set to null instead so if there are lingering threads that are looking for it, they won't block.
    }

    hidden [void]TargetInfoChanged($Response) {
        $Target = $Response.params.targetInfo
        $CdpPage = $this.GetPageByTargetId($Target.targetId)
        if ($CdpPage) {
            $CdpPage.TargetInfo['Url'] = $Target.Url
            $CdpPage.Title = $Target.Title
            $CdpPage.ProcessId = $Target.pid
        }
    }

    hidden [void]AttachedToTarget($Response) {
        $SessionId = $Response.params.sessionId
        $CdpPage = $this.GetPageByTargetId($Response.params.targetInfo.targetId)
        $CdpPage.TargetInfo['SessionId'] = $SessionId
        $this.SharedState.Sessions[$SessionId] = $CdpPage
        $CdpPage.SessionReady.Set()
    }

    hidden [void]DetachedFromTarget($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.params.sessionId)
        $CdpPage.TargetInfo.AddOrUpdate('SessionId', $null, { param($Key, $OldValue) $null })
        $null = $this.SharedState.Sessions.TryRemove($Response.params.sessionId, [ref]$null)
    }

    hidden [void]ExecutionContextsCleared($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage.RuntimeReady.IsSet) { $CdpPage.RuntimeReady.Reset() }
        foreach ($CdpFrame in $CdpPage.Frames.GetEnumerator()) {
            if ($CdpFrame.RuntimeReady.IsSet) {
                $CdpFrame.Dispose()
            }
        }
        $CdpPage.Frames.Clear()
    }

    hidden [void]ExecutionContextCreated($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $FrameId = $Response.params.context.auxData.frameId
        if ($CdpPage.TargetId -eq $FrameId) {
            $CdpPage.PageInfo.AddOrUpdate('RuntimeUniqueId', $Response.params.context.uniqueId, { param($Key, $OldValue) $Response.params.context.uniqueId } )
            $CdpPage.RuntimeReady.Set()
        } else {
            $Frame = $CdpPage.Frames.GetOrAdd($FrameId, [CdpFrame]::new($FrameId, $Response.sessionId))
            $Frame.PageInfo['RuntimeUniqueId'] = $Response.params.context.uniqueId
            $Frame.RuntimeReady.Set()
        }
    }

    [CdpPage]GetPageBySessionId([string]$SessionId) {
        return $this.SharedState.Sessions[$SessionId]
    }

    [CdpPage]GetPageByTargetId([string]$TargetId) {
        $CdpPage = $null
        if (!$this.SharedState.Targets.TryGetValue($TargetId, [ref]$CdpPage)) {
            $CdpPageReady = $this.NewTargets.GetOrAdd($TargetId, [System.Threading.ManualResetEventSlim]::new($false))
            $CdpPageReady.Wait()
        }
        return $this.SharedState.Targets[$TargetId]
    }
}

enum WaitForResponse {
    None = 0
    Message
    CommandId
}

# [NoRunspaceAffinity()]
class CdpServer {
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$SharedState = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Management.Automation.Runspaces.RunspacePool]$RunspacePool
    [System.Diagnostics.Process]$ChromeProcess
    [pscustomobject]$Threads = @{
        MessageReader = $null
        MessageReaderHandle = $null
        MessageProcessor = [System.Collections.Generic.List[Powershell]]::new()
        MessageProcessorHandle = [System.Collections.Generic.List[IAsyncResult]]::new()
        MessageWriter = $null
        MessageWriterHandle = $null
    }

    CdpServer([string]$BrowserPath, [object]$StreamOutput, [string[]]$BrowserArgs, [int]$AdditionalThreads, [hashtable]$Callbacks) {
        $this.Init($BrowserPath, $StreamOutput, $BrowserArgs, $AdditionalThreads, $Callbacks, $null)
    }

    CdpServer([string]$BrowserPath, [object]$StreamOutput, [string[]]$BrowserArgs, [int]$AdditionalThreads, [hashtable]$Callbacks, [System.Management.Automation.Runspaces.InitialSessionState]$State) {
        $this.Init($BrowserPath, $StreamOutput, $BrowserArgs, $AdditionalThreads, $Callbacks, $State)
    }

    hidden [void]Init([string]$BrowserPath, [object]$StreamOutput, [string[]]$BrowserArgs, [int]$AdditionalThreads, [hashtable]$Callbacks, [System.Management.Automation.Runspaces.InitialSessionState]$State) {
        $this.SharedState.IO = @{
            PipeWriter = [System.IO.Pipes.AnonymousPipeServerStream]::new([System.IO.Pipes.PipeDirection]::Out, [System.IO.HandleInheritability]::Inheritable)
            PipeReader = [System.IO.Pipes.AnonymousPipeServerStream]::new([System.IO.Pipes.PipeDirection]::In, [System.IO.HandleInheritability]::Inheritable)
            UnprocessedResponses = [System.Collections.Concurrent.BlockingCollection[object]]::new()
            CommandQueue = [System.Collections.Concurrent.BlockingCollection[object]]::new()
        }

        $this.SharedState.CdpServer = $this
        $this.SharedState.MessageHistory = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
        $this.SharedState.CommandHistory = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
        $this.SharedState.CommandId = 0
        $this.SharedState.Targets = [System.Collections.Concurrent.ConcurrentDictionary[string, CdpPage]]::new()
        $this.SharedState.Sessions = [System.Collections.Concurrent.ConcurrentDictionary[string, CdpPage]]::new()
        $this.SharedState.Callbacks = [System.Collections.Generic.Dictionary[string, scriptblock]]::new()

        foreach ($Key in $Callbacks.Keys) {
            $UpperKey = $Key.ToUpper()
            $this.SharedState.Callbacks[$UpperKey] = $Callbacks[$UpperKey]
        }

        $this.SharedState.Commands = @{
            SendRuntimeEvaluate = $this.CreateDelegate($this.SendRuntimeEvaluate)
        }

        $this.SharedState.EventHandler = [CdpEventHandler]::new($this.SharedState) # New-UnboundClassInstance -type ([CdpEventHandler]) -arguments @($this.SharedState)

        if (!$State) {
            $State = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
        }

        $State.ImportPSModule("$PSScriptRoot\PSChromeDevToolsServer")
        $RunspaceSharedState = [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('SharedState', $this.SharedState, $null)
        $State.Variables.Add($RunspaceSharedState)
        $State.ThrowOnRunspaceOpenError = $true
        $this.RunspacePool = [RunspaceFactory]::CreateRunspacePool(3, 3 + $AdditionalThreads, $State, $StreamOutput)
        $this.RunspacePool.Open()

        if (($BrowserArgs -like '--user-data-dir*').Count -ne 1) { throw '--user-data-dir is required.' }
        $BrowserArgs += (' --remote-debugging-pipe --remote-debugging-io-pipes={0},{1}' -f $this.SharedState.IO.PipeWriter.GetClientHandleAsString(), $this.SharedState.IO.PipeReader.GetClientHandleAsString())

        $StartInfo = [System.Diagnostics.ProcessStartInfo]::new()
        $StartInfo.FileName = $BrowserPath
        $StartInfo.Arguments = $BrowserArgs
        $StartInfo.UseShellExecute = $false

        $this.ChromeProcess = [System.Diagnostics.Process]::Start($StartInfo)

        while (!$this.SharedState.IO.PipeWriter.IsConnected -and !$this.SharedState.IO.PipeReader.IsConnected) {
            Start-Sleep -Milliseconds 1
        }

        $this.SharedState.IO.PipeWriter.DisposeLocalCopyOfClientHandle()
        $this.SharedState.IO.PipeReader.DisposeLocalCopyOfClientHandle()
    }

    [void]StartMessageReader() {
        if ($this.RunspacePool.GetAvailableRunspaces() -eq 0) { throw 'no runspaces available in runspacepool' }

        $this.Threads.MessageReader = [powershell]::Create()
        $this.Threads.MessageReader.RunspacePool = $this.RunspacePool
        $null = $this.Threads.MessageReader.AddScript({
                if ($SharedState.DebugPreference) { $DebugPreference = $SharedState.DebugPreference }
                if ($SharedState.VerbosePreference) { $VerbosePreference = $SharedState.VerbosePreference }

                $Buffer = [byte[]]::new(1024)
                $StringBuilder = [System.Text.StringBuilder]::new()
                $NullTerminatedString = "`0"

                while ($SharedState.IO.PipeReader.IsConnected) {
                    # Will hang here until something comes through the pipe.
                    $BytesRead = $SharedState.IO.PipeReader.Read($Buffer, 0, $Buffer.Length)
                    $null = $StringBuilder.Append([System.Text.Encoding]::UTF8.GetString($Buffer, 0, $BytesRead))

                    $HasCompletedMessages = if ($StringBuilder.Length) { $StringBuilder.ToString($StringBuilder.Length - 1, 1) -eq $NullTerminatedString } else { $false }
                    if ($HasCompletedMessages) {
                        $RawResponse = $StringBuilder.ToString()
                        $SplitResponse = @(($RawResponse -split $NullTerminatedString).Where({ "`0" -ne $_ }) | ConvertFrom-Json)
                        foreach ($Response in $SplitResponse) {
                            $SharedState.IO.UnprocessedResponses.Add($Response)
                        }
                        $StringBuilder.Clear()
                    }
                }
            }
        )
        $this.Threads.MessageReaderHandle = $this.Threads.MessageReader.BeginInvoke()
    }

    [void]StartMessageProcessor() {
        if ($this.RunspacePool.GetAvailableRunspaces() -eq 0) { throw 'no runspaces available in runspacepool' }

        $Powershell = [powershell]::Create()
        $this.Threads.MessageProcessor.Add($Powershell)
        $Powershell.RunspacePool = $this.RunspacePool
        $null = $Powershell.AddScript({
                if ($SharedState.DebugPreference) { $DebugPreference = $SharedState.DebugPreference }
                if ($SharedState.VerbosePreference) { $VerbosePreference = $SharedState.VerbosePreference }

                foreach ($Response in $SharedState.IO.UnprocessedResponses.GetConsumingEnumerable()) {
                    if ($Response.id) {
                        $SharedState.CommandHistory[$Response.id].Response = $Response
                        switch ($SharedState.CommandHistory[$Response.id].WaitForResponse) {
                            'None' { break }
                            Default { $SharedState.CommandHistory[$Response.id].CommandReady.Set() }
                        }
                    } else {
                        $SharedState.EventHandler.ProcessEvent($Response)
                    }

                    $SharedState.MessageHistory.Enqueue($Response)
                    while ($SharedState.MessageHistory.Count -gt 300) {
                        $SharedState.MessageHistory.TryDequeue([ref]$null)
                    }
                }
            }
        )
        $this.Threads.MessageProcessorHandle.Add($Powershell.BeginInvoke())
    }

    [void]StartMessageWriter() {
        if ($this.RunspacePool.GetAvailableRunspaces() -eq 0) { throw 'no runspaces available in runspacepool' }

        $this.Threads.MessageWriter = [powershell]::Create()
        $this.Threads.MessageWriter.RunspacePool = $this.RunspacePool
        $null = $this.Threads.MessageWriter.AddScript({
                if ($SharedState.DebugPreference) { $DebugPreference = $SharedState.DebugPreference }
                if ($SharedState.VerbosePreference) { $VerbosePreference = $SharedState.VerbosePreference }

                foreach ($CommandBytes in $SharedState.IO.CommandQueue.GetConsumingEnumerable()) {
                    $SharedState.IO.PipeWriter.Write($CommandBytes, 0, $CommandBytes.Length)
                }
            }
        )
        $this.Threads.MessageWriterHandle = $this.Threads.MessageWriter.BeginInvoke()
    }

    [void]Stop() {
        $this.SharedState.IO.PipeReader.Dispose()
        $this.SharedState.IO.PipeWriter.Dispose()
        $this.SharedState.IO.UnprocessedResponses.CompleteAdding()
        $this.SharedState.IO.CommandQueue.CompleteAdding()
        if ($this.Threads.MessageReaderHandle) {
            $this.Threads.MessageReader.EndInvoke($this.Threads.MessageReaderHandle)
            $this.Threads.MessageReader.Dispose()
        }
        if ($this.Threads.MessageProcessorHandle.Count -gt 0) {
            for ($i = 0; $i -lt $this.Threads.MessageProcessorHandle.Count; $i++) {
                $this.Threads.MessageProcessor[$i].EndInvoke($this.Threads.MessageProcessorHandle[$i])
            }
        }
        if ($this.Threads.MessageWriterHandle) {
            $this.Threads.MessageWriter.EndInvoke($this.Threads.MessageWriterHandle)
            $this.Threads.MessageWriter.Dispose()
        }
        $this.SharedState.IO.UnprocessedResponses.Dispose()
        $this.SharedState.IO.CommandQueue.Dispose()
        $this.ChromeProcess.Dispose()
        $this.RunspacePool.Dispose()
    }

    [void]SendCommand([hashtable]$Command) {
        $this.SendCommand($Command, [WaitForResponse]::None)
    }

    [object]SendCommand([hashtable]$Command, [WaitForResponse]$WaitForResponse) {
        if (!$this.SharedState.IO.PipeWriter.IsConnected) { throw 'Server is disconnected.' }

        # This should be the only place where $this.SharedState.CommandId is incremented.
        $CommandId = $this.SharedState.AddOrUpdate('CommandId', 1, { param($Key, $OldValue) $OldValue + 1 })

        $null = $this.SharedState.CommandHistory.TryAdd($CommandId, @{
                Method = $Command.method
                CommandReady = [System.Threading.ManualResetEventSlim]::new($false)
                WaitForResponse = $WaitForResponse
                Response = $null
            }
        )

        $Command.id = $CommandId
        $JsonCommand = $Command | ConvertTo-Json -Depth 10 -Compress
        $CommandBytes = [System.Text.Encoding]::UTF8.GetBytes($JsonCommand) + 0
        $this.SharedState.IO.CommandQueue.Add($CommandBytes)

        $Response = switch ($WaitForResponse) {
            ([WaitForResponse]::None) {
                $this.SharedState.CommandHistory[$CommandId].CommandReady.Dispose()
                $this.SharedState.CommandHistory[$CommandId].CommandReady = $null
                $null
                break
            }
            ([WaitForResponse]::Message) {
                $this.SharedState.CommandHistory[$CommandId].CommandReady.Wait()
                $this.SharedState.CommandHistory[$CommandId].Response
                $this.SharedState.CommandHistory[$CommandId].CommandReady.Dispose()
                $this.SharedState.CommandHistory[$CommandId].CommandReady = $null
                break
            }
            ([WaitForResponse]::CommandId) {
                $CommandId
                break
            }
        }

        return $Response
    }

    [CdpPage]GetPageByTargetId([string]$TargetId) {
        $CdpPage = $null
        if (!$this.SharedState.Targets.TryGetValue($TargetId, [ref]$CdpPage)) {
            $CdpPageReady = $this.SharedState.EventHandler.NewTargets.GetOrAdd($TargetId, [System.Threading.ManualResetEventSlim]::new($false))
            $CdpPageReady.Wait()
        }
        return $this.SharedState.Targets[$TargetId]
    }

    [void]SendRuntimeEvaluate([string]$SessionId, [string]$Expression) {
        $Command = @{
            method = 'Runtime.evaluate'
            sessionId = $SessionId
            params = @{
                expression = $Expression
            }
        }
        $this.SendCommand($Command)
    }

    [void]EnableDefaultEvents() {
        $Command = Get-Target.setDiscoverTargets
        $this.SendCommand($Command)

        $Command = Get-Target.setAutoAttach
        $this.SendCommand($Command)
    }

    [object]ShowMessageHistory() {
        $Events = $this.SharedState.MessageHistory.GetEnumerator() | Select-Object -Property @(
            @{Name = 'id'; Expression = { $_.id } },
            @{Name = 'method'; Expression = { if ($_.method) { $_.method } else { $this.SharedState.CommandHistory[$_.id].Method } } },
            @{Name = 'error'; Expression = { $_.error } },
            @{Name = 'sessionId'; Expression = { $_.sessionId } },
            @{Name = 'params'; Expression = { $_.params } },
            @{Name = 'result'; Expression = { $_.result } }
        )
        return $Events
    }

    [void]WaitForPageLoad([CdpPage]$CdpPage, [int]$Timeout) {
        if (!$CdpPage.LoadingState['Load'].Wait($Timeout)) { throw 'Page Load event was not fired.' }
        if (!$CdpPage.LoadingState['FrameStoppedLoading'].Wait($Timeout)) { throw 'Page FrameStoppedLoading event was not fired.' }
        if (!$CdpPage.RuntimeReady.Wait($Timeout)) { throw 'Page Runtime context id was not created in time.' }

        # Wait for all child frames to load and have executioncontext
        foreach ($CdpFrame in $CdpPage.Frames.GetEnumerator()) {
            if (!$CdpFrame.Value.LoadingState['Load'].Wait($Timeout)) { throw 'Frame Load event was not fired.' }
            if (!$CdpFrame.Value.LoadingState['FrameStoppedLoading'].Wait($Timeout)) { throw 'Frame FrameStoppedLoading event was not fired.' }
            if (!$CdpFrame.Value.RuntimeReady.Wait($Timeout)) { throw 'Frame Runtime context id was not created in time.' }
        }
    }

    [void]SetupNewPage([CdpPage]$CdpPage) {
        $CdpPage.SessionReady.Wait()
        $SessionId = $CdpPage.TargetInfo['SessionId']

        $Command = Get-Runtime.enable $SessionId
        $null = $this.SendCommand($Command, [WaitForResponse]::Message)

        $CdpPage.RuntimeReady.Wait()

        $Command = Get-Page.enable $SessionId
        $null = $this.SendCommand($Command, [WaitForResponse]::Message)

        $Command = Get-Page.setLifecycleEventsEnabled $SessionId $true
        $null = $this.SendCommand($Command, [WaitForResponse]::Message)

        foreach ($CdpFrame in $CdpPage.Frames.GetEnumerator()) {
            $CdpFrame.Value.RuntimeReady.Wait()
        }
    }

    hidden [Delegate]CreateDelegate([System.Management.Automation.PSMethod]$Method) {
        return $this.CreateDelegate($Method, $this)
    }

    hidden [Delegate]CreateDelegate([System.Management.Automation.PSMethod]$Method, $Target) {
        $reflectionMethod = if ($Target.GetType().Name -eq 'PSCustomObject') {
            $Target.psobject.GetType().GetMethod($Method.Name)
        } else {
            $Target.GetType().GetMethod($Method.Name)
        }
        $parameterTypes = [System.Linq.Enumerable]::Select($reflectionMethod.GetParameters(), [func[object, object]] { $args[0].parametertype })
        $concatMethodTypes = $parameterTypes + $reflectionMethod.ReturnType
        $delegateType = [System.Linq.Expressions.Expression]::GetDelegateType($concatMethodTypes)
        $delegate = [delegate]::CreateDelegate($delegateType, $Target, $reflectionMethod.Name)
        return $delegate
    }
}

function ConvertTo-FlatNode {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object]$Node,
        [string]$TopParent = $null,
        [bool]$IsShadowRoot
    )

    foreach ($Child in $Node) {
        # Determine the ultimate parent for this child
        $IsHead = if ($Child.nodeName -eq 'HEAD') {
            $Child.nodeName
        } else {
            $TopParent
        }

        $FlatNode = [PSCustomObject]@{
            TopParentName = $IsHead
            IsShadowRoot = $IsShadowRoot
            NodeId = $Child.nodeId
            NodeType = $Child.nodeType
            ParentId = $Child.parentId
            BackendNodeId = $Child.backendNodeId
            NodeValue = $Child.nodeValue
            NodeName = $Child.nodeName
            LocalName = $Child.localName
            Attributes = $null
            FrameId = $Child.frameId
            AttributesString = $Child.attributes
            DocumentURL = $Child.documentURL
            # ShadowRoots = $Child.shadowRoots
            # ContentFrame = $Child.contentFrame
        }

        $FlatNode.Attributes = if ($Child.attributes) {
            for ($i = 0; $i -lt $Child.attributes.Count; $i += 2) {
                [pscustomobject]@{
                    Name = $Child.attributes[$i]
                    Value = $Child.attributes[$i + 1]
                }
            }
        }

        if ($Child.Children) {
            ConvertTo-FlatNode -Node $Child.Children -TopParent $IsHead -IsShadowRoot $IsShadowRoot
        }

        if ($Child.contentDocument) {
            # $FlatNode.contentFrame = ConvertTo-FlatNode -Node $Child.contentDocument -TopParent $null
            ConvertTo-FlatNode -Node $Child.contentDocument -TopParent $null -IsShadowRoot $IsShadowRoot
        }

        if ($Child.shadowRoots) {
            ConvertTo-FlatNode -Node $Child.shadowRoots -TopParent $null -IsShadowRoot $true
        }

        $FlatNode
    }
}


function Get-DOM.describeNode {
    param($SessionId)
    @{
        method = 'DOM.describeNode'
        sessionId = $SessionId
        params = @{}
    }
}
function Get-DOM.disable {
    param($SessionId)
    @{
        method = 'DOM.disable'
        sessionId = $SessionId
    }
}
function Get-DOM.getBoxModel {
    param($SessionId)
    @{
        method = 'DOM.getBoxModel'
        sessionId = $SessionId
        params = @{}
    }
}
function Get-DOM.getDocument {
    param($SessionId)
    @{
        method = 'DOM.getDocument'
        sessionId = $SessionId
        params = @{
            depth = -1
            pierce = $true
        }
    }
}
function Get-DOM.setFileInputFiles {
    param($SessionId, $Files, $BackendNodeId)
    @{
        method = 'DOM.setFileInputFiles'
        sessionId = $SessionId
        params = @{
            files = @($Files)
            backendNodeId = $BackendNodeId
        }
    }
}

function Get-Input.dispatchKeyEvent {
    param($SessionId, $Text)
    @{
        method = 'Input.dispatchKeyEvent'
        sessionId = $SessionId
        params = @{
            type = 'char'
            text = $Text
        }
    }
}
function Get-Input.dispatchMouseEvent {
    param($SessionId, $Type, $X, $Y, $Button)
    @{
        method = 'Input.dispatchMouseEvent'
        sessionId = $SessionId
        params = @{
            type = $Type
            button = $Button
            clickCount = 0
            x = $X
            y = $Y
        }
    }
}

function Get-Page.bringToFront {
    param($SessionId)
    @{
        method = 'Page.bringToFront'
        sessionId = $SessionId
    }
}
function Get-Page.enable {
    param($SessionId)
    @{
        method = 'Page.enable'
        sessionId = $SessionId
    }
}
function Get-Page.navigate {
    param($SessionId, $Url)
    @{
        method = 'Page.navigate'
        sessionId = $SessionId
        params = @{
            url = $Url
        }
    }
}
function Get-Page.getFrameTree {
    param($SessionId)
    @{
        method = 'Page.getFrameTree'
        sessionId = $SessionId
    }
}
function Get-Page.setLifecycleEventsEnabled {
    param($SessionId, [bool]$Enabled)
    @{
        method = 'Page.setLifecycleEventsEnabled'
        sessionId = $SessionId
        params = @{
            enabled = $Enabled
        }
    }
}

function Get-Runtime.addBinding {
    param($SessionId, $Name)
    @{
        method = 'Runtime.addBinding'
        sessionId = $SessionId
        params = @{
            name = $Name
        }
    }
}
function Get-Runtime.enable {
    param($SessionId)
    @{
        method = 'Runtime.enable'
        sessionId = $SessionId
    }
}
function Get-Runtime.evaluate {
    param($SessionId, $Expression)
    @{
        method = 'Runtime.evaluate'
        sessionId = $SessionId
        params = @{
            expression = $Expression
        }
    }
}

function Get-Target.createTarget {
    param($Url)
    @{
        method = 'Target.createTarget'
        params = @{
            url = $Url
        }
    }
}

function Get-Target.createBrowserContext {
    param()
    @{
        method = 'Target.createBrowserContext'
        params = @{
            disposeOnDetach = $true
        }
    }
}

function Get-Target.setAutoAttach {
    param()
    @{
        method = 'Target.setAutoAttach'
        params = @{
            autoAttach = $true
            waitForDebuggerOnStart = $false
            filter = @(
                @{
                    type = 'service_worker'
                    exclude = $true
                },
                @{
                    type = 'worker'
                    exclude = $true
                },
                @{
                    type = 'browser'
                    exclude = $true
                },
                @{
                    type = 'tab'
                    exclude = $true
                },
                # @{
                # type = 'other'
                # exclude = $true
                # },
                @{
                    type = 'background_page'
                    exclude = $true
                },
                @{}
            )
            flatten = $true
        }
    }
}

function Get-Target.setDiscoverTargets {
    param($Url)
    @{
        method = 'Target.setDiscoverTargets'
        params = @{
            discover = $true
            filter = @(
                @{
                    type = 'service_worker'
                    exclude = $true
                },
                @{
                    type = 'worker'
                    exclude = $true
                },
                @{
                    type = 'browser'
                    exclude = $true
                },
                @{
                    type = 'tab'
                    exclude = $true
                },
                # @{
                # type = 'other'
                # exclude = $true
                # },
                @{
                    type = 'background_page'
                    exclude = $true
                },
                @{}
            )
        }
    }
}

function Get-CdpFrameTree {
    param($Tree)
    if ($Tree.frame) { $Tree.frame }
    if ($Tree.childFrames) {
        foreach ($Child in $Tree.childFrames) {
            Get-CdpFrameTree $Child
        }
    }
}

$script:Powershell = $null

function New-UnboundClassInstance ([Type] $type, [object[]] $arguments = $null) {
    if ($null -eq $script:Powershell) {
        $script:Powershell = [powershell]::Create()
        $script:Powershell.AddScript({
                function New-UnboundClassInstance ([Type] $type, [object[]] $arguments) {
                    [activator]::CreateInstance($type, $arguments)
                }
            }.Ast.GetScriptBlock()
        ).Invoke()
        $script:Powershell.Commands.Clear()
    }

    try {
        if ($null -eq $arguments) { $arguments = @() }
        $result = $script:Powershell.AddCommand('New-UnboundClassInstance').
        AddParameter('type', $type).
        AddParameter('arguments', $arguments).
        Invoke()
        return $result
    } finally {
        $script:Powershell.Commands.Clear()
    }
}

function ConvertTo-Delegate {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [System.Management.Automation.PSMethod[]]$Method,
        [Parameter(Mandatory)]
        [object]$Target
    )

    process {
        $reflectionMethod = if ($Target.GetType().Name -eq 'PSCustomObject') {
            $Target.psobject.GetType().GetMethod($Method.Name)
        } else {
            $Target.GetType().GetMethod($Method.Name)
        }
        $parameterTypes = [System.Linq.Enumerable]::Select($reflectionMethod.GetParameters(), [func[object, object]] { $args[0].parametertype })
        $concatMethodTypes = $parameterTypes + $reflectionMethod.ReturnType
        $delegateType = [System.Linq.Expressions.Expression]::GetDelegateType($concatMethodTypes)
        $delegate = [delegate]::CreateDelegate($delegateType, $Target, $reflectionMethod.Name)
        $delegate
    }
}

function Get-CdpFrame {
    <#
        .SYNOPSIS
        Gets a frame from the Frametree if it exists.
        .PARAMETER Url
        The regex pattern of a url to look for
        .PARAMETER Timeout
        Max time to wait(ms) before giving up.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory)]
        [string]$Url,
        [int]$Timeout = 5000
    )

    begin {
        $PollInterval = 100
        $Sequence = 0
    }

    process {
        $TimeoutTime = (Get-Date).AddMilliseconds($Timeout)
        do {
            $Sequence++

            $Command = Get-Page.getFrameTree $CdpPage.TargetInfo['SessionId']
            $Response = $CdpPage.CdpServer.SendCommand($Command, [WaitForResponse]::Message)

            $FramesTree = Get-CdpFrameTree $Response.result.frameTree

            $Match = $FramesTree.url | Select-String -Pattern $Url
            $MatchedFrame = $FramesTree | Where-Object { $_.url -eq $Match.Line }

            $CdpFrame = $CdpPage.Frames.Values | Where-Object { $_.FrameId -eq $MatchedFrame.id }

            if ($CdpFrame) { break }
            Start-Sleep -Milliseconds ([math]::Min(($PollInterval * $Sequence), 1000))
        } while (($TimeoutTime - (Get-Date)).Milliseconds -gt 0)

        if (!$CdpFrame) { throw ('Timed out. No frame found using: {0}' -f $Url) }

        [pscustomobject]@{
            CdpPage = $CdpPage
            CdpFrame = $CdpFrame
        }
    }
}

function Invoke-CdpCommand {
    <#
        .SYNOPSIS
        Invokes the provided cdp command with parameters on the CdpPage.
        All commands can be found here:
        https://chromedevtools.github.io/devtools-protocol/tot/
        .PARAMETER MethodName
        The name of the cdp method ex 'Page.navigate'
        .PARAMETER Parameters
        A hashtable of the parameters.
        Excluding id, method, and sessionId
        @{
            url = 'about:blank'
        }
        .EXAMPLE
        $Response = Invoke-CdpCommand -CdpPage $CdpPage -Method 'Page.navigate' -Parameters @{url = 'about:blank' }
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory)]
        [string]$MethodName,
        [object]$Parameters
    )

    process {
        $CdpServer = $CdpPage.CdpServer

        $Command = @{
            method = $MethodName
            sessionId = $CdpPage.TargetInfo['SessionId']
        }

        if ($Parameters) {
            $Command.params = $Parameters
        }

        $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
    }
}

function Invoke-CdpInputClickElement {
    <#
        .SYNOPSIS
        Finds and clicks with element in the center of the box. Clicks from the top left of the element when $TopLeft is switched on.
        If this induces navigation, use Test-CdpSelector to wait for the new url then follow with Wait-CdpLifecycleEvent.
        .PARAMETER FilterScript
        The scriptblock that will filter find valid nodes.
        Valid properties are:

        NodeId
        NodeType
        ParentId
        BackendNodeId
        NodeValue*
        NodeName*
        LocalName
        Attributes*
        FrameId
        AttributesString
        DocumentURL

        *The most common selectors
        NodeValue = any text on the page
        NodeName = element tag name
        Attributes = attributes for the tag such as:
            Name = id, Value = theId
            Name = autofocus

        .EXAMPLE
        $FilterScript = {
            $_.NodeName -eq '#text' -and
            $_.NodeValue -eq 'Woo woo'
        }

        $FilterScript = {
            $_.NodeName -eq 'a'
        }
        $Index = 5

        $FilterScript = {
            $_.NodeName -eq 'button'
        }
        $Index = 0

        $FilterScript = {
            $_.NodeValue -eq 'submit'
        }

        .PARAMETER Index
        The nth number of the Nodes found by FilterScript/Selector.
        Default to the first one if more than one is returned unless specified.
        .PARAMETER Click
        Number of times to left click the mouse
        .PARAMETER OffsetX
        Number of pixels to offset from the center of the element on the X axis
        .PARAMETER OffsetY
        Number of pixels to offset from the center of the element on the Y axis
        .PARAMETER TopLeft
        Clicks from the top left of the element instead of center. Offset x and y will be relative to this position instead.
        .PARAMETER BringToFront
        Attemps to brings page to front once before sending click.
        .PARAMETER Delay
        Time in ms between each mouse down and mouse up command.
        .PARAMETER Selector
        QuerySelectorAll syntax to find the element.
        See Test-CdpSelector
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory, ParameterSetName = 'Scriptblock')]
        [scriptblock]$FilterScript,
        [int]$Index = 0,
        [int]$Click = 0,
        [int]$OffsetX = 0,
        [int]$OffsetY = 0,
        [switch]$TopLeft,
        [switch]$BringToFront,
        [ValidateRange(0, [int]::MaxValue)]
        [int]$Delay = 1,
        [Parameter(Mandatory, ParameterSetName = 'QuerySelectorAll')]
        [string]$Selector
    )

    process {
        $CdpServer = $CdpPage.CdpServer
        $SessionId = $CdpPage.TargetInfo['SessionId']

        $DisableDomCommand = Get-DOM.disable $SessionId

        $CdpPage.PageInfo['Node'] = if ($FilterScript) {
            Test-CdpSelector -CdpPage $CdpPage -FilterScript $FilterScript -Index $Index -EnableDomEvents
        } else {
            Test-CdpSelector -CdpPage $CdpPage -Selector $Selector -Index $Index -EnableDomEvents
        }

        if ($CdpPage.PageInfo['Node'].nodeType -ne 1 -and $CdpPage.PageInfo['Node'].nodeType -ne 3) {
            $CdpServer.SendCommand($DisableDomCommand)
            throw ('Node is not an element or text. {0}' -f $CdpPage.PageInfo['Node'].nodeType)
        }

        if ($Click -le 0) {
            $CdpServer.SendCommand($DisableDomCommand)
            return $_
        }

        # Need to scroll into view before getting the BoxModel
        $Command = @{
            method = 'DOM.scrollIntoViewIfNeeded'
            sessionId = $SessionId
            params = @{
                backendNodeId = $CdpPage.PageInfo['Node'].BackendNodeId
            }
        }
        $null = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)

        $Command = Get-DOM.getBoxModel $SessionId
        $Command.params = @{
            nodeId = $CdpPage.PageInfo['Node'].nodeId
        }
        $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)

        if ($Response.error) {
            $CdpServer.SendCommand($DisableDomCommand)
            throw 'Could not get box model. {0}' -f "$($Response.error)"
        }

        # Disable dom events now that we don't need nodes anymore.
        $CdpServer.SendCommand($DisableDomCommand)

        $CdpPage.PageInfo['BoxModel'] = $Response.result.model

        if ($TopLeft) {
            $PixelX = $CdpPage.PageInfo['BoxModel'].content[0] + $OffsetX
            $PixelY = $CdpPage.PageInfo['BoxModel'].content[1] + $OffsetY
        } else {
            $PixelX = $CdpPage.PageInfo['BoxModel'].content[0] + ($CdpPage.PageInfo['BoxModel'].width / 2) + $OffsetX
            $PixelY = $CdpPage.PageInfo['BoxModel'].content[1] + ($CdpPage.PageInfo['BoxModel'].height / 2) + $OffsetY
        }

        $Command = Get-Input.dispatchMouseEvent $SessionId 'mousePressed' $PixelX $PixelY 'left'
        $Command.params.clickCount = $Click

        if ($BringToFront) {
            $CommandFront = Get-Page.bringToFront $SessionId
            $null = $CdpServer.SendCommand($CommandFront, [WaitForResponse]::Message)
        }

        $CommandIds = @(
            $CdpServer.SendCommand($Command, [WaitForResponse]::CommandId)
            $Command.params.type = 'mouseReleased'
            Start-Sleep -Milliseconds $Delay # if we send click too fast it can fail to register.
            $CdpServer.SendCommand($Command, [WaitForResponse]::CommandId)
        )

        foreach ($Id in $CommandIds) {
            $History = $CdpServer.SharedState.CommandHistory[$Id]
            $History.CommandReady.Wait()
            $History.CommandReady.Dispose()
            $History.CommandReady = $null
        }

        if ($_) { $_ }
    }
}

function Invoke-CdpInputSendKeys {
    <#
        .SYNOPSIS
        Sends keys to a session.
        If this induces navigation, use Test-CdpSelector to wait for the new url then follow with Wait-CdpLifecycleEvent.
        .PARAMETER Keys
        String to send.
        Include "$([char]13)" to press enter at any given point in the string.
        .EXAMPLE
        Invoke-CdpInputSendKeys -CdpPage $CdpPage -Keys "Hello World$([char]13)"
        .PARAMETER BringToFront
        Attemps to brings page to front once before sending keys.
        .PARAMETER Delay
        Time in ms between sending each key command.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory)]
        [string]$Keys,
        [switch]$BringToFront,
        [ValidateRange(0, [int]::MaxValue)]
        [int]$Delay = 1
    )

    process {
        $CdpServer = $CdpPage.CdpServer
        $SessionId = $CdpPage.TargetInfo['SessionId']
        $Command = Get-Input.DispatchKeyEvent $SessionId $null

        if ($BringToFront) {
            $CommandFront = Get-Page.bringToFront $SessionId
            $null = $CdpServer.SendCommand($CommandFront, [WaitForResponse]::Message)
        }

        $CommandIds = foreach ($Char in $Keys[0..($Keys.Length - 1)]) {
            $Command.params.text = $Char
            Start-Sleep -Milliseconds $Delay # if we send keys too fast it can fail to register.
            $CdpServer.SendCommand($Command, [WaitForResponse]::CommandId)
        }

        foreach ($Id in $CommandIds) {
            $History = $CdpServer.SharedState.CommandHistory[$Id]
            $History.CommandReady.Wait()
            $History.CommandReady.Dispose()
            $History.CommandReady = $null
        }

        if ($_) { $_ }
    }
}

function Invoke-CdpPageCaptureScreenshot {
    <#
        .SYNOPSIS
        Creates a screenshot of the current page. The browser will be brought to front for the screenshot.
        .PARAMETER FilePath
        The fullname of the screenshot file.
        .PARAMETER Format
        Supports 'jpeg', 'png', or 'webp'
        .PARAMETER X
        Pixel X axis to start from.
        .PARAMETER Y
        Pixel Y axis to start from.
        .PARAMETER Width
        Width from Pixel X.
        .PARAMETER Height
        Height from Pixel Y.
        .PARAMETER Scale
        Scale of the screenshot.
        .PARAMETER BringToFront
        If the browser is minimized, the command will hang until it is not minimized. This switch will automatically bring the browser to front to avoid hanging.
    #>

    [CmdletBinding(DefaultParameterSetName = 'CommonSize')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [string]$FilePath,
        [ValidateSet('jpeg', 'png', 'webp')]
        [string]$Format = 'png',
        [Parameter(ParameterSetName = 'Viewport')]
        [int]$X,
        [Parameter(ParameterSetName = 'Viewport')]
        [int]$Y,
        [Parameter(ParameterSetName = 'Viewport')]
        [int]$Width,
        [Parameter(ParameterSetName = 'Viewport')]
        [int]$Height,
        [Parameter(ParameterSetName = 'Viewport')]
        [ValidateRange(0.1, 2)]
        [decimal]$Scale = 1,
        [switch]$BringToFront
    )

    process {
        $CdpServer = $CdpPage.CdpServer
        $Command = @{
            method = 'Page.captureScreenshot'
            sessionId = $CdpPage.TargetInfo['SessionId']
        }

        if ($PSCmdlet.ParameterSetName.Contains('Viewport')) {
            $Command.params = @{
                format = $Format
                clip = @{
                    x = $X
                    y = $Y
                    width = $Width
                    height = $Height
                    scale = $Scale
                }
            }
        } else {
            $Command.params = @{format = $Format }
        }

        if ($BringToFront) {
            $CommandFront = Get-Page.bringToFront $CdpPage.TargetInfo['SessionId']
            $null = $CdpServer.SendCommand($CommandFront, [WaitForResponse]::Message)
        }

        $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
        if ($Response.error) { throw ('Could not screenshot. {0}' -f $Response.error) }
        [System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Response.result.data))
        $CdpServer.SharedState.CommandHistory[$Response.id].Response.result.data = $null # remove base64 string after writing since it is large.

        if ($_) { $_ }
    }
}

function Invoke-CdpPageNavigate {
    <#
        .SYNOPSIS
        Navigates and automatically waits for the page to load with Page.lifecycleEvent.load and FrameStoppedLoading
        Also waits for frames to load if they are present
        .PARAMETER Timeout
        Max amount of time to wait for page to load before throwing.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory)]
        [string]$Url,
        [int]$Timeout = 60000
    )

    process {
        $CdpServer = $CdpPage.CdpServer
        $SessionId = $CdpPage.TargetInfo['SessionId']

        $CdpPage.ResetLoadingState()
        $CdpPage.RuntimeReady.Reset()
        foreach ($CdpFrame in $CdpPage.Frames.GetEnumerator()) {
            $CdpFrame.Value.Dispose()
        }
        $CdpPage.Frames.Clear()

        $Command = Get-Page.navigate $SessionId $Url
        $null = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)

        $CdpServer.WaitForPageLoad($CdpPage, $Timeout)

        if ($_) { $_ }
    }
}

function Invoke-CdpPagePrintToPdf {
    <#
        .SYNOPSIS
        Prints page to pdf.
        https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-printToPDF
        .PARAMETER FilePath
        The fullname of the pdf.
        .PARAMETER PageRanges
        Paper ranges to print, one based, e.g., '1-5, 8, 11-13'.
        Pages are printed in the document order, not in the order specified, and no more than once.
        Defaults to empty string, which implies the entire document is printed.
        The page numbers are quietly capped to actual page count of the document, and ranges beyond the end of the document are ignored.
        If this results in no pages to print, an error is reported.
        It is an error to specify a range with start greater than end.
        .PARAMETER HeaderTemplate
        HTML template for the print header. Should be valid HTML markup with following classes used to inject printing values into them:

        date: formatted print date
        title: document title
        url: document location
        pageNumber: current page number
        totalPages: total pages in the document

        For example, <span class=title></span> would generate span containing the title.
        .PARAMETER FooterTemplate
        See HeaderTemplate.
    #>

    [CmdletBinding(DefaultParameterSetName = 'CommonSize')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [string]$FilePath,
        [bool]$Landscape,
        [bool]$DisplayHeaderFooter,
        [bool]$PrintBackground = $true,
        [ValidateRange(0.1, 2)]
        [decimal]$Scale = 1,
        [Parameter(ParameterSetName = 'CommonSize')]
        [ValidateSet('A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'Letter', 'Legal')]
        [string]$PaperSize = 'Letter',
        [Parameter(ParameterSetName = 'UserSize')]
        [decimal]$PaperWidth,
        [Parameter(ParameterSetName = 'UserSize')]
        [decimal]$PaperHeight,
        [decimal]$MarginTop,
        [decimal]$MarginBottom,
        [decimal]$MarginLeft,
        [decimal]$MarginRight,
        [string[]]$PageRanges,
        [string]$HeaderTemplate,
        [string]$FooterTemplate
    )

    begin {
        $Width, $Height = switch ($PaperSize) {
            'A0' { 33.1; 46.8 }
            'A1' { 23.4; 33.1 }
            'A2' { 16.5; 23.4 }
            'A3' { 11.7; 16.5 }
            'A4' { 8.3; 11.7 }
            'A5' { 5.8; 8.3 }
            'Letter' { 8.5; 11 }
            'Legal' { 8.5; 14 }
        }
    }

    process {
        $CdpServer = $CdpPage.CdpServer
        $Command = @{
            method = 'Page.printToPDF'
            sessionId = $CdpPage.TargetInfo['SessionId']
        }

        $Command.params = @{
            landscape = $Landscape
            displayHeaderFooter = $DisplayHeaderFooter
            printBackground = $PrintBackground
            scale = $Scale
            paperWidth = if ($Width) { $Width } else { $PaperWidth }
            paperHeight = if ($Height) { $Height } else { $PaperHeight }
            marginTop = $MarginTop
            marginBottom = $MarginBottom
            marginLeft = $MarginLeft
            marginRight = $MarginRight
            pageRanges = if ($PageRanges) { @($PageRanges) } else { '' }
            headerTemplate = $HeaderTemplate
            footerTemplate = $FooterTemplate
        }

        $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
        if ($Response.error) { throw ('Did not print. {0}' -f $Response.error) }
        [System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Response.result.data))
        $CdpServer.SharedState.CommandHistory[$Response.id].Response.result.data = $null # remove base64 string after writing since it is large.

        if ($_) { $_ }
    }
}

function Invoke-CdpRuntimeAddBinding {
    <#
        .SYNOPSIS
        Adds a binding object to the browser
        .PARAMETER Name
        Name of the object to use in javascript - window.Name(json);
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory)]
        [string]$Name
    )

    process {
        $CdpServer = $CdpPage.CdpServer
        $SessionId = $CdpPage.TargetInfo['SessionId']
        $Command = Get-Runtime.addBinding $SessionId $Name
        $CdpServer.SendCommand($Command)

        if ($_) { $_ }
    }
}

function Invoke-CdpRuntimeEvaluate {
    <#
        .SYNOPSIS
        Run javascript on the browser and return the responses in:
        $CdpPage.PageInfo['EvaluateResult'] = $Response.result.result
        $CdpPage.PageInfo['EvaluateResponse'] = $Response
        .PARAMETER Expression
        The javascript expression to run.
        .PARAMETER AwaitPromise
        Use if the Expression includes a promise that needs to be awaited.

        .EXAMPLE
        This returns after ~3-4 seconds rather than 2+2+2=6 seconds
        If AwaitPromise was not used, Invoke-CdpRuntimeEvaluate will return immediately with $Result.result.result = javascript promise object.

        $Expression = @'
function timedPromise(name, delay) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(`${name} resolved`);
        }, delay);
    });
}

async function awaitMultiplePromises() {
    const promise1 = timedPromise("Promise 1", 2000);
    const promise2 = timedPromise("Promise 2", 2000);
    const promise3 = timedPromise("Promise 3", 2000);

    const results = await Promise.all([promise1, promise2, promise3]);

    const displayBox = document.querySelector("[id=textInput]");
    displayBox.value = results;

    return 'Promise was awaited.'
}

awaitMultiplePromises();
'@
    $StartTime = Get-Date
    $Result = Invoke-CdpRuntimeEvaluate -CdpPage $CdpPage -Expression $Expression -AwaitPromise
    $EndTime = Get-Date
    ($EndTime - $StartTime).TotalSeconds
    $Result.result.result

    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [Parameter(Mandatory)]
        [string]$Expression,
        [switch]$AwaitPromise
    )

    process {
        $CdpServer = $CdpPage.CdpServer
        $SessionId = $CdpPage.TargetInfo['SessionId']
        $Command = Get-Runtime.evaluate $SessionId $Expression
        $CdpPage.RuntimeReady.Wait()
        $Command.params.uniqueContextId = "$($CdpPage.PageInfo['RuntimeUniqueId'])"
        if ($AwaitPromise) { $Command.params.awaitPromise = $true }
        $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)

        $CdpPage.PageInfo['EvaluateResult'] = $Response.result.result
        $CdpPage.PageInfo['EvaluateResponse'] = $Response

        if ($_) { $_ }
    }
}

function New-CdpPage {
    <#
        .SYNOPSIS
        Creates a new tab target and enables Page events, PageLifeCycle events, and Runtime.
        .PARAMETER NewWindow
        Creates a new browser context and tab.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline, Position = 0)]
        [Parameter(ParameterSetName = 'ByPage')]
        [CdpPage]$CdpPage,
        [Parameter(ValueFromPipeline, Position = 0)]
        [Parameter(ParameterSetName = 'ByServer')]
        [CdpServer]$CdpServer,
        [string]$Url = 'about:blank',
        [switch]$NewWindow
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByPage') { $CdpServer = $CdpPage.CdpServer }

        if ($NewWindow) {
            $Command = Get-Target.createBrowserContext
            $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
        }

        $Command = Get-Target.createTarget $Url
        if ($NewWindow) {
            $Command.params.newWindow = $true
            $Command.params.browserContextId = $Response.result.browserContextId
        } else {
            $Command.params.browserContextId = $CdpPage.TargetInfo['BrowserContextId']
        }

        $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
        $CdpPage = $CdpServer.GetPageByTargetId($Response.result.targetId)

        $CdpServer.SetupNewPage($CdpPage)

        $CdpPage
    }
}

function Send-CdpDomUploadFile {
    <#
        .SYNOPSIS
        Bypasses file upload dialog by providing the file path to upload in the found input element by FilterScript.
        .PARAMETER Files
        Fullname of files to upload
        .PARAMETER FilterScript
        The scriptblock that will filter find valid nodes.
        See Test-CdpSelector
        .PARAMETER Selector
        QuerySelectorAll syntax to find the element.
        See Test-CdpSelector
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [CdpPage]$CdpPage,
        [string[]]$Files,
        [Parameter(Mandatory, ParameterSetName = 'Scriptblock')]
        [scriptblock]$FilterScript,
        [Parameter(Mandatory, ParameterSetName = 'QuerySelectorAll')]
        [string]$Selector,
        [int]$Index = 0
    )

    process {
        $CdpServer = $CdpPage.CdpServer

        $Node = if ($FilterScript) {
            Test-CdpSelector -CdpPage $CdpPage -FilterScript $FilterScript -Index $Index -EnableDomEvents
        } else {
            Test-CdpSelector -CdpPage $CdpPage -Selector $Selector -Index $Index -EnableDomEvents
        }

        $Command = Get-Dom.setFileInputFiles $CdpPage.TargetInfo['SessionId'] $Files $Node.BackendNodeId
        $Response = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
        if ($Response.error) { throw ('Could not upload file: {0}' -f $Response.error) }

        if ($_) { $_ }
    }
}

function Start-CdpServer {
    <#
        .SYNOPSIS
        Starts the CdpServer by launching the browser process, initializing the event handlers, and starting the message reader, processor, and writer threads.
        .PARAMETER StartPage
        The URL of the page to load when the browser starts.
        .PARAMETER UserDataDir
        The directory to use for the browser's user data profile. This should be a unique directory for each instance of the server to avoid conflicts.
        .PARAMETER BrowserArgs
        Commandline args for chromium.
        Must NOT include --user-data-dir=. This is added by UserDataDir parameter.
        Must NOT include --remote-debugging-pipe and --remote-debugging-io-pipe if using pipes.
        .PARAMETER BrowserPath
        The path to the browser executable to launch
        .PARAMETER AdditionalThreads
        Sets the max runspaces the pool can use + 3.
        Default runspacepool uses 3min and 3max threads for MessageReader, MessageProcessor, MessageWriter
        A number higher than 0 increases the maximum runspaces for the pool.

        More MessageProcessor can be started with $CdpServer.MessageProcessor()
        These will be queued forever if the max number of runspaces are exhausted in the pool.
        .PARAMETER Callbacks
        A hashtable of scriptblocks to be invoked for specific events. The keys should be the event names without the domain prefix and preceeded by 'On'. For example:
        @{
            OnLoadEventFired = { param($Response) $Response.params }
        }
        .PARAMETER DisableDefaultEvents
        This stops targets from being auto attached and auto discovered.
        .PARAMETER StreamOutput
        This is the (Get-Host)/$Host Console which runspacepool streams will output to.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Parameter(ParameterSetName = 'DefaultArgs')]
        [string]$StartPage,
        [Parameter(Mandatory)]
        [Parameter(ParameterSetName = 'DefaultArgs')]
        [Parameter(ParameterSetName = 'UserArgs')]
        [ValidateScript({ Test-Path $_ -PathType Container -IsValid })]
        [string]$UserDataDir,
        [Parameter(ParameterSetName = 'UserArgs')]
        [string[]]$BrowserArgs,
        [Parameter(ParameterSetName = 'UserArgs')]
        [System.Management.Automation.Runspaces.InitialSessionState]$State,
        [Parameter(Mandatory)]
        [string]$BrowserPath,
        [ValidateScript({ $_ -ge 0 })]
        [int]$AdditionalThreads = ([int]$env:NUMBER_OF_PROCESSORS * 2),
        [hashtable]$Callbacks,
        [object]$StreamOutput
    )

    $LockFile = Join-Path -Path $UserDataDir -ChildPath 'lockfile'
    if (Test-Path -Path $LockFile -PathType Leaf) { throw 'Browser is already open. Please close it and run Start-CdpServer again.' }

    if ($PSCmdlet.ParameterSetName -eq 'DefaultArgs') {
        $BrowserArgs = @(
            ('--user-data-dir="{0}"' -f $UserDataDir)
            '--no-first-run'
            $StartPage
        ) | Where-Object { $_ -ne '' -and $null -ne $_ }
    } else {
        $BrowserArgs += (' --user-data-dir="{0}"' -f $UserDataDir)
    }

    $ConsoleHost = if ($StreamOutput) { $StreamOutput } else { (Get-Host) }
    $CdpServer = New-UnboundClassInstance CdpServer -arguments $BrowserPath, $ConsoleHost, $BrowserArgs, $AdditionalThreads, $Callbacks, $State

    if ($PSBoundParameters.ContainsKey('Debug')) {
        $CdpServer.SharedState.DebugPreference = 'Continue'
    }

    if ($PSBoundParameters.ContainsKey('Verbose')) {
        $CdpServer.SharedState.VerbosePreference = 'Continue'
    }

    $CdpServer.StartMessageReader()
    $CdpServer.StartMessageProcessor()
    $CdpServer.StartMessageWriter()

    $CdpServer.EnableDefaultEvents()

    # Should only be used at startup since there is only one page
    do {
        foreach ($Target in $CdpServer.SharedState.Targets.GetEnumerator()) {
            break
        }
        Start-Sleep -Milliseconds 1
    } while (!$Target)
    $CdpPage = $CdpServer.SharedState.Targets[$Target.Value.TargetId]

    $CdpServer.SetupNewPage($CdpPage)

    $CdpPage
}

function Stop-CdpServer {
    <#
        .SYNOPSIS
        Disposes the Server Pipes, Threads, ChromeProcess, and RunspacePool
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline, Position = 0)]
        [CdpPage]$CdpPage,
        [Parameter(ValueFromPipeline, Position = 0)]
        [CdpServer]$CdpServer
    )

    process {
        if ($CdpPage) { $CdpServer = $CdpPage.CdpServer }
        $CdpServer.Stop()
    }
}

function Test-CdpSelector {
    <#
        .SYNOPSIS
        Returns nodes for exploring if selectors are found.
        Will poll until found or $Timeout is reached.
        .PARAMETER FilterScript
        The scriptblock that will filter find valid nodes.
        Valid properties are:

        NodeId
        NodeType
        ParentId
        BackendNodeId
        NodeValue*
        NodeName*
        LocalName
        Attributes*
        FrameId
        AttributesString
        DocumentURL

        *The most common selectors
        NodeValue = any text on the page
        NodeName = element tag name
        Attributes = attributes for the tag such as:
            Name = id, Value = theId
            Name = autofocus

        .EXAMPLE
        $FilterScript = {
            $_.NodeName -eq '#text' -and
            $_.NodeValue -eq 'Woo woo'
        }

        $FilterScript = {
            $_.NodeName -eq 'a'
        }
        $Index = 5

        $FilterScript = {
            $_.NodeName -eq 'button'
        }
        $Index = 0

        $FilterScript = {
            $_.NodeValue -eq 'submit'
        }

        .PARAMETER Index
        The nth number of the Nodes found by FilterScript

        .PARAMETER All
        Returns all found nodes for viewing

        .PARAMETER EnableDomEvents
        Keeps DOM events active. Mainly for internal use.

        .PARAMETER Selector
        QuerySelectorAll syntax to find the element.
        '#id'
        '.class'
        '[id=id]'
        'div > div'
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]$CdpPage,
        [Parameter(Mandatory, ParameterSetName = 'Scriptblock')]
        [scriptblock]$FilterScript,
        [int]$Index = 0,
        [switch]$All,
        [switch]$EnableDomEvents,
        [int]$Timeout = 5000,
        [Parameter(Mandatory, ParameterSetName = 'QuerySelectorAll')]
        [string]$Selector
    )

    begin {
        $PollInterval = 100
        $Sequence = 0
    }

    process {
        $CdpServer = $CdpPage.CdpServer
        $DocumentCommand = Get-DOM.getDocument $CdpPage.TargetInfo['SessionId']

        $EndTime = (Get-Date).AddMilliseconds($Timeout)

        while ($true) {
            $Sequence++

            $Response = $CdpServer.SendCommand($DocumentCommand, [WaitForResponse]::Message)

            if ($PSCmdlet.ParameterSetName.Contains('Scriptblock')) {
                $Root = $Response.result.root
                $Document = ConvertTo-FlatNode -Node $Root
                $Nodes = $Document | Where-Object { $_.TopParentName -ne 'HEAD' } | Where-Object -FilterScript $FilterScript
            } else {
                $Command = @{
                    method = 'DOM.querySelectorAll'
                    sessionId = $CdpPage.TargetInfo['SessionId']
                    params = @{
                        nodeId = $Response.result.root.nodeId
                        selector = $Selector
                    }
                }
                $QuerySelectorResponse = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
                if ($QuerySelectorResponse.error) { throw ('Invalid selector: {0}' -f $QuerySelectorResponse.error) }

                $Nodes = foreach ($NodeId in $QuerySelectorResponse.result.nodeIds) {
                    $Command = Get-DOM.describeNode $CdpPage.TargetInfo['SessionId']
                    $Command.params.nodeId = $NodeId
                    $DescribeResponse = $CdpServer.SendCommand($Command, [WaitForResponse]::Message)
                    ConvertTo-FlatNode -Node $DescribeResponse.result.node
                }
            }

            if ($Nodes) {
                break
            } elseif (($EndTime - (Get-Date)).TotalMilliseconds -lt 0) {
                $SelectorValue = if ($FilterScript) { $FilterScript } else { $Selector }
                throw ('No node found in allotted time with: {0}' -f $SelectorValue)
            } else {
                Start-Sleep -Milliseconds ([math]::Min(($PollInterval * $Sequence), 1000))
            }
        }

        if (!$EnableDomEvents) {
            $Command = Get-DOM.disable $CdpPage.TargetInfo['SessionId']
            $CdpServer.SendCommand($Command)
        }

        if ($Nodes -and $All) { $Nodes }
        else { $Nodes[$Index] }
    }
}

function Wait-CdpPageLifecycleEvent {
    <#
        .SYNOPSIS
        Waits for provided LifecycleEvents.
        .PARAMETER InputObject
        The CdpPage or [pscustomobject]@{CdpPage; CdpFrame} from Get-CdpFrame.
        .PARAMETER Events
        The LifecycleEvent to wait for.
        FirstPaint does not always fire, such as on about:blank.
        There needs to be viewable text or renderable objects excluding frames, as frames have their own paintable content.
        .PARAMETER Timeout
        Max time to wait(ms) before giving up.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]$InputObject,
        [ValidateSet('NetworkIdle', 'FirstPaint')]
        [string[]]$Events = @('NetworkIdle'),
        [int]$Timeout = 5000
    )

    process {
        if ($InputObject.CdpPage) {
            $CdpPage = $InputObject.CdpPage
            $Target = $InputObject.CdpFrame
        } else {
            $CdpPage = $InputObject
            $Target = $InputObject
        }

        $Events | ForEach-Object {
            if (!$Target.LoadingState[$_].Wait($Timeout)) {
                throw ('Event did not fire in {0}ms. Try setting a higher timeout or make sure the page has paintable content.' -f $Timeout)
            }
        }

        if ($_) { $CdpPage }
    }
}