packages feed

haskell-debugger-0.13.0.0: test/haskell/Test/DAP.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}

module Test.DAP
  ( module Test.DAP
  , module Test.DAP.Init
  , module Test.DAP.Messages
  ) where

import qualified Data.Map as M
import Control.Concurrent.STM
import Control.Monad.IO.Class
import Data.Aeson
import Control.Monad.Reader
import Test.DAP.Init
import Test.DAP.Messages
import Test.DAP.Messages.Parser
import Control.Concurrent.Async
import Control.Monad.Cont
import Control.Monad
import Data.Maybe
import qualified Data.HashMap.Strict as H
import System.FilePath
import qualified Data.Text as T
import Data.Aeson.Types
import Test.Tasty.HUnit
import DAP (ScopesArguments(..), StackTraceArguments(..), DisconnectArguments(..))
import qualified DAP
import Test.DAP.Orphans ()
import DAP.Types

import qualified Data.List as List

--------------------------------------------------------------------------------
-- * DSL
--------------------------------------------------------------------------------
-- fill as needed; some parts of the highest level DSL will prefer NOT to be sync.

next, stepIn :: TestDAP ()
next   = void . sync $ nextRequest @Value @Value Null
stepIn = void . sync $ stepInRequest @Value @Value Null

threads :: TestDAP [Thread]
threads = do
  Response{responseBody=Just ThreadsResponse{threads=ts}} <- sync threadsRequest
  return ts

stackTrace :: Int -> TestDAP [StackFrame]
stackTrace tid = do
  Response{responseBody=Just StackTraceResponse{stackFrames=fs}} <- sync $ stackTraceRequest $
    StackTraceArguments
      { DAP.stackTraceArgumentsThreadId = tid
      , DAP.stackTraceArgumentsStartFrame = Nothing
      , DAP.stackTraceArgumentsLevels = Nothing
      , DAP.stackTraceArgumentsFormat = Nothing
      }
  return fs

scopes :: Int {- stack frame id -} -> TestDAP [Scope]
scopes frameId = do
  Response{responseBody=Just ScopesResponse{scopes=scs}} <- sync $ scopesRequest $
    ScopesArguments { DAP.scopesArgumentsFrameId = frameId }
  return scs

configurationDone :: ResponseCont Value a -> TestDAP a
configurationDone = configurationDoneRequest Nothing

--------------------------------------------------------------------------------
-- ** "Scenarios"
--------------------------------------------------------------------------------

-- | Register handler that will reply to runInTerminal reverse request
handleRunInTerminal :: AsyncCont (Maybe (H.HashMap T.Text T.Text), [T.Text]) (a, Int)
                    -- ^ Continuation receives async with args of runInTerm req.
                    --
                    -- - Waiting means block waiting for reverse request to be received
                    -- - Returns the process id of the launched runInTerminal process.
                    -> TestDAP a
handleRunInTerminal k = do
  ctx <- ask
  liftIO $
    withAsync (upd <$> runTestDAP waitForReverseRequest ctx) $ \a ->
      runTestDAP (k a >>= \(x, pid) -> do
        respondWithBody 0{-hardcode seqn-} "runInTerminal" (object [ "shellProcessId" .= pid ])
        return x
        ) ctx
  where
    upd :: Value -> (Maybe (H.HashMap T.Text T.Text), [T.Text])
    upd orig =
      fromMaybe (error $ "Failed to parse runInTerminal request: " ++ show orig) $
      parseMaybe parser orig

    parser :: Value -> Parser (Maybe (H.HashMap T.Text T.Text), [T.Text])
    parser = withObject "runInTerminal request" $ \o -> do
      ("request" :: String) <- o .: "type"
      ("runInTerminal" :: String) <- o .: "command"
      argsObj <- o .: "arguments"
      withObject "runInTerminal arguments" (\a -> do
        env :: Maybe (H.HashMap T.Text T.Text) <- a .:? "env"
        args :: [T.Text] <- a .: "args"
        pure (env, args)
        ) argsObj

-- | Launch a config, run to completion and assert the exit code.
runToEnd :: LaunchConfig -> TestDAP ()
runToEnd cfg = do
  _ <- sync $ launchWith cfg
  waitFiltering_ EventTy "initialized"
  _ <- sync configurationDone
  waitForExitCode 0

--------------------------------------------------------------------------------
-- ** Custom launch configs
--------------------------------------------------------------------------------

-- | Launch configuration for the debugger. Mirrors the NodeJS test config.
data LaunchConfig = LaunchConfig
  { lcProjectRoot :: FilePath
  , lcEntryFile :: Maybe FilePath
  , lcEntryPoint :: Maybe String
  , lcEntryArgs :: [String]
  , lcExtraGhcArgs :: [String]
  , lcInternalInterpreter :: Maybe Bool
  }

-- | A launch config with the given project root and entry file. @entryPoint@
-- defaults to @main@ and there are no extra args.
mkLaunchConfig :: FilePath -> FilePath -> LaunchConfig
mkLaunchConfig projectRoot entryFile = LaunchConfig
  { lcProjectRoot = projectRoot
  , lcEntryFile = Just entryFile
  , lcEntryPoint = Just "main"
  , lcEntryArgs = []
  , lcExtraGhcArgs = []
  , lcInternalInterpreter = Nothing
  }

-- | Launch the debugger with a 'LaunchConfig'.
launchWith :: LaunchConfig -> ResponseCont Value a -> TestDAP a
launchWith LaunchConfig{..} = launch $ object $
  [ "projectRoot" .= lcProjectRoot
  , "request" .= ("launch" :: String)
  ] ++
  [ "entryFile" .= (lcProjectRoot </> ef) | Just ef <- [lcEntryFile] ] ++
  [ "entryPoint" .= ep | Just ep <- [lcEntryPoint] ] ++
  [ "entryArgs" .= lcEntryArgs ] ++
  [ "extraGhcArgs" .= lcExtraGhcArgs ] ++
  [ "internalInterpreter" .= b | Just b <- [lcInternalInterpreter] ]

-- | Set breakpoints in a particular source file of a project at the given
-- lines.
setLineBreakpoints :: FilePath -- ^ project root
                   -> FilePath -- ^ entry file (relative to project root)
                   -> [Int]
                   -> ResponseCont Value a -> TestDAP a
setLineBreakpoints projectRoot entryFile lines_ =
  setBreakpointsIn projectRoot entryFile
    [ defaultSourceBreakpoint { sourceBreakpointLine = l } | l <- lines_ ]

-- | Set breakpoints (with arbitrary 'SourceBreakpoint's) in a particular
-- source file of a project.
setBreakpointsIn :: FilePath -- ^ project root
                 -> FilePath -- ^ source file (relative to project root)
                 -> [SourceBreakpoint]
                 -> ResponseCont Value a -> TestDAP a
setBreakpointsIn projectRoot entryFile bps =
  setBreakpointsRequest DAP.SetBreakpointsArguments
    { DAP.setBreakpointsArgumentsSource = DAP.defaultSource
        { DAP.sourceName = Just (T.pack (takeFileName entryFile))
        , DAP.sourcePath = Just (T.pack (projectRoot </> entryFile))
        }
    , DAP.setBreakpointsArgumentsBreakpoints = Just bps
    , DAP.setBreakpointsArgumentsLines = Just [sourceBreakpointLine bp | bp <- bps]
    , DAP.setBreakpointsArgumentsSourceModified = Just False
    }

-- | Launch, configure breakpoint, hit breakpoint.
hitBreakpointWith :: LaunchConfig -> Int -> TestDAP ()
hitBreakpointWith cfg@LaunchConfig{..} line = do
  entryFile <- maybe (fail "hitBreakpointWith: missing entryFile") pure lcEntryFile
  hitBreakpointIn cfg entryFile line

-- | Launch, configure a breakpoint in the given source file (relative to
-- the project root) at the given line, then wait for it to be hit.
hitBreakpointIn :: LaunchConfig -> FilePath -> Int -> TestDAP ()
hitBreakpointIn cfg@LaunchConfig{..} bpFile line = do
  _ <- sync $ launchWith cfg
  waitFiltering_ EventTy "initialized"
  _ <- sync $ setLineBreakpoints lcProjectRoot bpFile [line]
  _ <- sync configurationDone
  _ <- assertStoppedLocation DAP.StoppedEventReasonBreakpoint line
  return ()

--------------------------------------------------------------------------------
-- ** Variable inspection
--------------------------------------------------------------------------------
data VarsView = VarsView
  { varsViewDesc :: String
  , varsViewVars :: M.Map T.Text [Variable] }

mkVarsView :: String -> [Variable] -> VarsView
mkVarsView ctxDesc vs = VarsView
  { varsViewDesc = ctxDesc
  , varsViewVars = M.fromListWith (++) [(variableName v, [v]) | v <- vs] }

-- | Get variable by name
(%) :: VarsView -> String -> Variable
(%) vv n = case vv %% n of
  [v] -> v
  what -> error $ "Unexpected *many* variables by name " ++ show n ++ ": " ++ show what

-- | Get variables by name (there may be more than one with the same name; e.g. consider punning)
(%%) :: VarsView -> String -> [Variable]
(%%) VarsView{..} n
  | Just vs <- M.lookup (T.pack n) varsViewVars = vs
  | otherwise = error $
      "Variable " ++ show n ++ " not found in " ++ varsViewDesc ++ ": " ++ show (M.keys varsViewVars)
--------------------------------------------------------------------------------
-- | Fetch all variables from the scope with the given name in the top-most
-- frame of the first thread.
fetchScopeVars :: T.Text -> TestDAP VarsView
fetchScopeVars scopeName_ = do
  Response{responseBody=Just ThreadsResponse{threads=t:_}} <- sync threadsRequest
  Response{responseBody=Just StackTraceResponse{stackFrames=fr:_}} <- sync $ stackTraceRequest $
    StackTraceArguments
      { DAP.stackTraceArgumentsThreadId = threadId t
      , DAP.stackTraceArgumentsStartFrame = Nothing
      , DAP.stackTraceArgumentsLevels = Nothing
      , DAP.stackTraceArgumentsFormat = Nothing
      }
  Response{responseBody=Just ScopesResponse{scopes=scs}} <- sync $ scopesRequest $
    ScopesArguments { DAP.scopesArgumentsFrameId = stackFrameId fr }
  case List.find ((== scopeName_) . scopeName) scs of
    Nothing -> liftIO $ assertFailure $
      "fetchScopeVars: scope " ++ show scopeName_
        ++ " not found (" ++ show (map scopeName scs) ++ ")"
    Just sc -> do
      vs <- fetchChildren (scopeVariablesReference sc)
      pure $ mkVarsView ("scope " ++ show scopeName_) vs

fetchLocalVars :: TestDAP VarsView
fetchLocalVars = fetchScopeVars "Locals"

fetchModuleVars :: TestDAP VarsView
fetchModuleVars = fetchScopeVars "Module"

-- | Get the children of a variable by its variablesReference.
fetchChildren :: Int -> TestDAP [Variable]
fetchChildren ref = do
  Response{responseBody=Just (VariablesResponse vs)} <- sync $ variablesRequest $
    VariablesArguments
      { DAP.variablesArgumentsVariablesReference = ref
      , DAP.variablesArgumentsFilter = Nothing
      , DAP.variablesArgumentsStart = Nothing
      , DAP.variablesArgumentsCount = Nothing
      , DAP.variablesArgumentsFormat = Nothing
      }
  return vs

-- | Force a lazy variable by sending a 'variables' request for its reference
-- and returning the first child.
forceLazy :: HasCallStack => Variable -> TestDAP Variable
forceLazy v = do
  liftIO $ do
    assertEqual ("Variable " ++ show (variableName v) ++ " should be lazy")
      (Just True) (variablePresentationHintLazy =<< variablePresentationHint v)
    assertEqual ("Variable " ++ show (variableName v) ++ " should be \"_\" because it is lazy") "_" (variableValue v)
    assertBool ("Variable " ++ show (variableName v) ++ " should be expandable (because it is a lazy var)") $
      variableVariablesReference v /= 0 -- if it is expandable we get a reference to use to expand here

  -- Force the lazy variable by using its reference
  vs <- fetchChildren (variableVariablesReference v)
  case vs of
    (v':_) -> pure v'
    [] -> liftIO $ assertFailure $ "forceLazy: no children for forced variable " ++ show (variableName v)

-- | Expand a structured variable.
expandVar :: HasCallStack => Variable -> TestDAP VarsView
expandVar v = do
  liftIO $
    assertBool ("Variable " ++ show (variableName v) ++ " should be expandable (it should have structure)") $
      variableVariablesReference v /= 0
  vs <- fetchChildren (variableVariablesReference v)
  pure $ mkVarsView ("children of " ++ T.unpack (variableName v)) vs

-- | Assert that a variable has a given String value.
assertIsString :: HasCallStack => Variable -> T.Text -> TestDAP ()
assertIsString v expected = liftIO $ do
  assertEqual ("Variable " ++ show (variableName v) ++ " should be a String")
    (Just "String") (variableType v)
  assertEqual ("Variable " ++ show (variableName v) ++ " should be " ++ show expected)
    expected (variableValue v)
  assertEqual ("Variable " ++ show (variableName v) ++ " should not be expandable (because it is a String)")
    0 (variableVariablesReference v)

-- | Assert the value of some variable.
-- e.g. @var \@==? "\"hello\""@
(@==?) :: HasCallStack => Variable -> T.Text -> TestDAP ()
(@==?) v expected = liftIO $ assertEqual (T.unpack (variableValue v) ++ " @==? " ++ T.unpack expected) expected (variableValue v)

--------------------------------------------------------------------------------
-- ** Exception info & other requests
--------------------------------------------------------------------------------

exceptionInfo :: Int -> TestDAP ExceptionInfoResponse
exceptionInfo tid = do
  Response{responseBody=Just r} <- sync $ exceptionInfoRequest $
    ExceptionInfoArguments { DAP.exceptionInfoArgumentsThreadId = tid }
  return r

setBreakOnException :: TestDAP ()
setBreakOnException = do
  _ <- sync $ setExceptionBreakpointsRequest @_ @Value
    SetExceptionBreakpointsArguments
      { DAP.setExceptionBreakpointsArgumentsFilters = ["break-on-exception"]
      , DAP.setExceptionBreakpointsArgumentsFilterOptions = Nothing
      , DAP.setExceptionBreakpointsArgumentsExceptionOptions = Nothing
      }
  pure ()

continueThread :: Int -> TestDAP ()
continueThread tid = do
  _ <- sync $ continueRequest @_ @Value
    ContinueArguments
      { DAP.continueArgumentsThreadId = tid
      , DAP.continueArgumentsSingleThread = False
      }
  pure ()

evaluate :: T.Text -> TestDAP EvaluateResponse
evaluate expr = do
  Response{responseBody=Just r} <- sync $ evaluateRequest $
    EvaluateArguments
      { DAP.evaluateArgumentsExpression = expr
      , DAP.evaluateArgumentsFrameId = Nothing
      , DAP.evaluateArgumentsContext = Nothing
      , DAP.evaluateArgumentsFormat = Nothing
      }
  return r

stepOut :: Int -> TestDAP ()
stepOut tid = do
  _ <- sync $ stepOutRequest @_ @Value
    StepOutArguments
      { DAP.stepOutArgumentsThreadId = tid
      , DAP.stepOutArgumentsSingleThread = False
      , DAP.stepOutArgumentsGranularity = Nothing
      }
  pure ()

-- | Wait for an "exited" event and assert the exit code.
waitForExitCode :: Int -> TestDAP ()
waitForExitCode expected = do
  Event{eventBody = Just ExitedEvent{exitedEventExitCode}} <- waitFiltering EventTy "exited"
  liftIO $ assertEqual ("exit code should be " ++ show expected) expected exitedEventExitCode

disconnect :: TestDAP ()
disconnect = do
  Response{responseSuccess} <- sync $ disconnectRequest @_ @Value $ Just
    DisconnectArguments
      { DAP.disconnectArgumentsRestart = False
      , DAP.disconnectArgumentsTerminateDebuggee = True
      , DAP.disconnectArgumentsSuspendDebuggee = False
      }
  liftIO $ assertBool "disconnect response should indicate success" responseSuccess
  return ()

--------------------------------------------------------------------------------
-- ** Convenience methods (based on vscode-debugadapter-node/testSupport)
--------------------------------------------------------------------------------

launch :: Value {-^ Launch args -} -> ResponseCont Value a -> TestDAP a
launch args = runContT $
  ContT initializeRequest
    >>= liftIO . wait
    >> ContT (launchRequest args)

configurationSequence :: ResponseCont Value a -> TestDAP a
configurationSequence k = do
  waitFiltering_ EventTy "initialized"
  configurationDone k

-- | Assert that a "stopped" event with the given reason is received, then
-- fetch the stack trace and assert the top frame is at the expected line.
assertStoppedLocation :: HasCallStack => DAP.StoppedEventReason -> Int -> TestDAP ()
assertStoppedLocation reason expectedLine = do
  Event{eventBody = Just StoppedEvent{stoppedEventReason, stoppedEventThreadId}} <- waitFiltering EventTy "stopped"
  liftIO $ assertBool "Stopped reason matches expected reason" (stoppedEventReason == reason)
  frames <- maybe (pure []) stackTrace stoppedEventThreadId
  case frames of
    (frame:_) -> liftIO $ assertEqual "stopped location: line mismatch" expectedLine (stackFrameLine frame)
    []        -> liftIO $ assertFailure "assertStoppedLocation: no stack frames"

-- | Assert that all *pending*(not yet taken from channel) accumulated output
-- events (until any other event is found) contain a certain string
assertOutput :: HasCallStack => T.Text -> TestDAP ()
assertOutput expected = do
  events <- waitAccumulating EventTy "output"
  let outputs = map (outputEventOutput . fromMaybe (error "assertOutput:fromMaybe") . eventBody) events
  liftIO $
    assertBool
      ("assertOutput: expecting " ++ show expected ++ " but got " ++ show outputs)
      (any (T.isInfixOf expected) outputs)

assertFullOutput :: HasCallStack => T.Text -> TestDAP ()
assertFullOutput expected = assertFullOutputWith (expected <> " \\in full_output") (T.isInfixOf expected)

assertNotFullOutput :: HasCallStack => T.Text -> TestDAP ()
assertNotFullOutput expected = assertFullOutputWith (expected <> " \\notin full_output") (not . T.isInfixOf expected)

-- | Assert that the full output up until now matches any given string with the given function
assertFullOutputWith :: HasCallStack => T.Text -> (T.Text -> Bool) -> TestDAP ()
assertFullOutputWith assertStr test = do
  TestDAPClientContext{..} <- ask
  fullOut <- liftIO $ readTVarIO clientFullOutput
  liftIO $ assertBool
    ("assertFullOutputWith: asserting '" ++ show assertStr ++ "' but got " ++ show fullOut)
    (any test fullOut)

--------------------------------------------------------------------------------
-- * Waiting for messages
--------------------------------------------------------------------------------

data MsgType = EventTy | ResponseTy | ReverseRequestTy
  deriving Show

msgChan :: MsgType -> TestDAPClientContext -> TChan Value
msgChan ty TestDAPClientContext{..} = case ty of
  EventTy          -> clientEvents
  ResponseTy       -> clientResponses
  ReverseRequestTy -> clientReverseRequests

msgMatch :: MsgType -> String -> MessageMatch
msgMatch ty s = case ty of
  EventTy         -> eventMatch s
  ResponseTy      -> responseMatch s
  ReverseRequestTy -> reverseRequestMatch s

waitFiltering_ :: MsgType -> String -> TestDAP ()
waitFiltering_ ty s = void $ waitFiltering @Value ty s

-- | Drop messages of the given type until a message with the given
-- eventType/command is found. The matching message is returned.
-- FIXME: Timeouts on waiting, to avoid hanging forever in the testsuite!!
waitFiltering :: forall a. FromJSON a => MsgType -> String -> TestDAP a
waitFiltering ty s = do
  ch <- asks (msgChan ty)
  let mm = msgMatch ty s
  let loop = do
        v <- atomically $ readTChan ch -- block waiting for input
        if messageMatchMatches mm v
          then case fromJSON @a v of
            Error e -> error $ "waitFiltering: Failed to parse message MATCHING " ++ s ++ ":" ++ show ty ++ " with error: " ++ e ++ "\nFull message was: " ++ show v
            Success x -> return x
          else loop
  liftIO loop

-- | Accumulate messages of the given type until a message with a
-- eventType/command different from the given one is found.
--
-- The non-matching message is not consumed, nor returned, and will be kept in
-- the messages buffer.
waitAccumulating :: forall a. FromJSON a => MsgType -> String -> TestDAP [a]
waitAccumulating ty s = do
  ch <- asks (msgChan ty)
  let mm = msgMatch ty s
  let loop acc = do
        r <- atomically $ do
          v <- readTChan ch
          if messageMatchMatches mm v
            then case fromJSON @a v of
              Error e -> error $ "waitAccumulating: Failed to parse MATCHING message body with error: " ++ e ++ "\nFull message was: " ++ show v
              Success x -> pure (Just x)
            else Nothing <$ unGetTChan ch v
        case r of
          Nothing -> return (reverse acc)
          Just v  -> loop (v:acc)
  liftIO $ loop []

--------------------------------------------------------------------------------
-- * Protocol requests (based on vscode-debugadapter-node/testSupport)
--------------------------------------------------------------------------------

initializeRequest :: ResponseCont Value a -> TestDAP a
initializeRequest k = do
  TestDAPClientContext{clientSupportsRunInTerminal} <- ask
  customRequest "initialize"
    (Just $ object $
      [ "adapterID" .= ("haskell-debugger" :: String)
      , "clientID" .= ("mock-client" :: String)
      , "clientName" .= ("Mock Client" :: String)
      , "linesStartAt1" .= True
      , "columnsStartAt1" .= True
      , "locale" .= ("en" :: String)
      , "pathFormat" .= ("path" :: String)
      , "supportsRunInTerminalRequest" .= clientSupportsRunInTerminal
      ]) k

configurationDoneRequest :: Maybe Value -> ResponseCont Value a -> TestDAP a
configurationDoneRequest = customRequest "configurationDone"

launchRequest, attachRequest, restartRequest, setBreakpointsRequest,
  setFunctionBreakpointsRequest, setExceptionBreakpointsRequest,
  setInstructionBreakpointsRequest, dataBreakpointInfoRequest,
  setDataBreakpointsRequest, continueRequest, nextRequest, stepInRequest,
  stepOutRequest, stepBackRequest, reverseContinueRequest, restartFrameRequest,
  gotoRequest, pauseRequest, stackTraceRequest, scopesRequest, variablesRequest,
  setVariableRequest, sourceRequest, modulesRequest, evaluateRequest,
  disassembleRequest, stepInTargetsRequest, gotoTargetsRequest, completionsRequest,
  exceptionInfoRequest, readMemoryRequest, writeMemoryRequest :: (ToJSON a, FromJSON b) => a -> ResponseCont b r -> TestDAP r

launchRequest                    = requestWithArgs "launch"
attachRequest                    = requestWithArgs "attach"
restartRequest                   = requestWithArgs "restart"
setBreakpointsRequest            = requestWithArgs "setBreakpoints"
setFunctionBreakpointsRequest    = requestWithArgs "setFunctionBreakpoints"
setExceptionBreakpointsRequest   = requestWithArgs "setExceptionBreakpoints"
setInstructionBreakpointsRequest = requestWithArgs "setInstructionBreakpoints"
dataBreakpointInfoRequest        = requestWithArgs "dataBreakpointInfo"
setDataBreakpointsRequest        = requestWithArgs "setDataBreakpoints"
continueRequest                  = requestWithArgs "continue"
nextRequest                      = requestWithArgs "next"
stepInRequest                    = requestWithArgs "stepIn"
stepOutRequest                   = requestWithArgs "stepOut"
stepBackRequest                  = requestWithArgs "stepBack"
reverseContinueRequest           = requestWithArgs "reverseContinue"
restartFrameRequest              = requestWithArgs "restartFrame"
gotoRequest                      = requestWithArgs "goto"
pauseRequest                     = requestWithArgs "pause"
stackTraceRequest                = requestWithArgs "stackTrace"
scopesRequest                    = requestWithArgs "scopes"
variablesRequest                 = requestWithArgs "variables"
setVariableRequest               = requestWithArgs "setVariable"
sourceRequest                    = requestWithArgs "source"
modulesRequest                   = requestWithArgs "modules"
evaluateRequest                  = requestWithArgs "evaluate"
disassembleRequest               = requestWithArgs "disassemble"
stepInTargetsRequest             = requestWithArgs "stepInTargets"
gotoTargetsRequest               = requestWithArgs "gotoTargets"
completionsRequest               = requestWithArgs "completions"
exceptionInfoRequest             = requestWithArgs "exceptionInfo"
readMemoryRequest                = requestWithArgs "readMemory"
writeMemoryRequest               = requestWithArgs "writeMemory"

terminateRequest, disconnectRequest :: (ToJSON a, FromJSON b) => Maybe a -> ResponseCont b r -> TestDAP r
terminateRequest  = customRequest "terminate"
-- example:
--  object
--    [ "restart" .= False
--    , "terminateDebuggee" .= True
--    , "suspendDebuggee" .= False
--    ]
-- then...
-- wait "disconnected"
-- waitEventFiltering $ eventMatch "terminated"
disconnectRequest = customRequest "disconnect"

threadsRequest :: FromJSON b => ResponseCont b r -> TestDAP r
threadsRequest = customRequest "threads" (Nothing :: Maybe ())
--------------------------------------------------------------------------------
requestWithArgs :: (ToJSON a, FromJSON b) => String -> a -> ResponseCont b r -> TestDAP r
requestWithArgs command args = customRequest command (Just args)

-- example: respondWithBody revReqNum [ "shellProcessId" .= ... ]
respondWithBody :: Int -> String -> Value -> TestDAP ()
respondWithBody seqNum command body =
  reply seqNum $
    [ "type" .= ("response" :: String)
    , "command" .= command
    , "success" .= True
    , "body" .= body
    ]
--------------------------------------------------------------------------------
customRequest :: (ToJSON a, FromJSON b) => String -> Maybe a -> ResponseCont b r -> TestDAP r
customRequest command args = do
  send $
    [ "type" .= ("request" :: String)
    , "command" .= command
    ] ++ maybe [] (\v -> ["arguments" .= v]) args