packages feed

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

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
module Test.DAP.Init where

----------------------------------------------------------------------------
import Data.Maybe
import Data.List (isInfixOf)
import           Control.Exception hiding (handle)
import qualified Control.Exception as E
import           Network.Run.TCP
import           Network.Socket             (Family(AF_INET), SockAddr(SockAddrInet, SockAddrInet6), SocketOption(ReuseAddr), SocketType(Stream), bind, close, defaultProtocol, getSocketName, setSocketOption, socket, socketToHandle, tupleToHostAddress)
import           System.IO
import           Control.Retry
import           Data.IORef
import System.Environment (lookupEnv)
import System.FilePath ((</>), (<.>))
import qualified System.Process as P
----------------------------------------------------------------------------
import           Test.DAP.Messages
import Control.Concurrent.STM
import Control.Concurrent.Async
import Control.Monad.Reader
import Control.Monad
import Data.Aeson.Types
import Test.Tasty.HUnit (assertFailure)
import DAP.Server (readPayload)
import qualified Control.Monad.Catch
import Test.Utils (withHermeticDir)
import DAP.Types (OutputEvent (..))
import Test.DAP.Messages.Parser

--------------------------------------------------------------------------------
-- * Launch the DAP server process (what we're testing)
--------------------------------------------------------------------------------

data TestDAPServer = TestDAPServer
  { testDAPServerPort :: Int
  , testDAPServerCleanup :: IO ()
  , testDAPServerFlushOutput :: IO ()
  }

-- | Launch an @hdb server@ for tests on a random local port and capture stdout.
--   Prefer withTestDAPServer because it will terminate the process at the end.
startTestDAPServer :: FilePath -> [String] -> IO TestDAPServer
startTestDAPServer testDir flags = do
  testPort <- getAvailablePort
  let nameTemplate= testDir </> ("server_port_" ++ show testPort)
  let openHandle ext = do
        h <- openFile (nameTemplate <.> ext) WriteMode
        hSetBuffering h LineBuffering
        pure h
  hout <- openHandle "out"
  herr <- openHandle "err"
  (Just hin, Nothing, Nothing, p)
    <- P.createProcess (P.proc "hdb" $ ["server"] ++ flags ++ ["--port", show testPort])
        { P.cwd = Just testDir
        , P.std_out = P.UseHandle hout
        , P.std_err = P.UseHandle herr
        , P.std_in = P.CreatePipe
        }

  pid <- fromMaybe 0 <$> P.getPid p
  writeFile (nameTemplate <.> "pid") (show pid)

  let flushServerOutput = do
        putStrLn "\n--- SERVER OUTPUT ---"
        putStrLn $ "See: " ++ testDir
        putStrLn $ "Might need: KEEP_TEMP_DIRS=True"
        putStrLn "---------------------\n"

  pure TestDAPServer
    { testDAPServerPort = testPort
    , testDAPServerFlushOutput = flushServerOutput
    , testDAPServerCleanup = do
        P.cleanupProcess (Just hin, Just hout, Just herr, p)

    }

-- | Prefer this to startTestDAPServer
withTestDAPServer :: FilePath -> [String] -> (FilePath -> TestDAPServer -> IO a) -> IO a
withTestDAPServer dir flags check' = do
  keep_tmp_dirs <- maybe False read <$> lookupEnv "KEEP_TEMP_DIRS"
  withHermeticDir keep_tmp_dirs dir $ \test_dir ->
    bracket (startTestDAPServer test_dir flags)
      testDAPServerCleanup
      (check' test_dir)

getAvailablePort :: IO Int
getAvailablePort =
  bracket open close $ \sock -> do
    setSocketOption sock ReuseAddr 1
    bind sock (SockAddrInet 0 (tupleToHostAddress (0, 0, 0, 0)))
    getSocketName sock >>= \case
      SockAddrInet port _ -> pure (fromIntegral port)
      SockAddrInet6 port _ _ _ -> pure (fromIntegral port)
      addr -> error $ "getAvailablePort: unexpected socket address " ++ show addr
  where
    open = socket AF_INET Stream defaultProtocol

--------------------------------------------------------------------------------
-- * Launch the client connecting to the server (the test driver)
--------------------------------------------------------------------------------

-- | Like 'withTestDAPServerClientWith' but default:
--  - @RunInTerminal = False@
--  - @clientHandleNoSuccess = \_ _ -> pure Nothing@ (by default, success: false FAILS test)
withTestDAPServerClient :: TestDAPServer -> TestDAP a -> IO a
withTestDAPServerClient = withTestDAPServerClientWith False (\_ _ -> pure Nothing)

--- | Connect a test client to a running 'TestDAPServer', with retry semantics
--- and server log flushing on failure.
withTestDAPServerClientWith :: Bool {-^ Announce support for runInTerminal? -} -> (String -> Value -> IO (Maybe Value))
                            -> TestDAPServer -> TestDAP a -> IO a
withTestDAPServerClientWith clientSupportsRunInTerminal clientHandleNoSuccess server continue = do
  runClient `E.onException` testDAPServerFlushOutput server
  where
    runClient = do
      withNewClient (testDAPServerPort server) $ \clientHandle -> do
        clientNextSeqRef           <- newIORef 1
        clientReverseRequests      <- newTChanIO
        clientResponses            <- newTChanIO
        clientEvents               <- newTChanIO
        clientFullOutput           <- newTVarIO []
        let ctx = TestDAPClientContext{..}
        either id (\() -> error "handleServerTestDAP unexpectedly returned") <$> race
          (runTestDAP continue ctx)
          (runTestDAP handleServerTestDAP ctx)

-- | Spawns a new mock client that connects to the mock server.
withNewClient :: forall a. Int -- ^ Port
              -> (Handle -> IO a)
              -> IO a
withNewClient port continue = do
  recovering (constantDelay 50000 <> limitRetries 50) retry_handlers $ \_ ->
    runTCPClient "127.0.0.1" (show port) $ \sock -> do
      h <- socketToHandle sock ReadWriteMode
      hSetNewlineMode h NewlineMode { inputNL = CRLF, outputNL = CRLF }
      continue h `finally` hClose h
  where
    retry_handlers =
      skipAsyncExceptions ++
      [const $ Control.Monad.Catch.Handler $ \ (e :: IOException) -> return $ "Network.Socket.connect" `isInfixOf` show e]

--------------------------------------------------------------------------------
-- ** Handle server responses, events, and reverse requests
--------------------------------------------------------------------------------

-- | Forever: read messages from handle and write them either to clientNonEvents or clientEvents
handleServerTestDAP :: TestDAP ()
handleServerTestDAP = do
  TestDAPClientContext{..} <- ask
  forever $ do
    payload <- nextPayload
    liftIO $ case parseMaybe parseType payload of
      Just "event"    -> do
        let mtxt = fromJSON @(Event OutputEvent) payload
        atomically $ do
          writeTChan clientEvents payload
          case mtxt of
            Success (Event _ (Just txt)) -> modifyTVar' clientFullOutput (outputEventOutput txt:)
            _ -> pure ()
      Just "response" ->
        -- Fail immediately if the server reports failure, even if the test
        -- is blocked waiting for some other specific message --
        -- unless the test has opted into handling failed responses itself.
        case parseMaybe parseSuccess payload of
          Just errMsg -> clientHandleNoSuccess errMsg payload >>= \case
            Just v    -> atomically $ writeTChan clientResponses v
            Nothing   -> assertFailure errMsg
          Nothing     -> atomically $ writeTChan clientResponses payload
      Just "request"  -> atomically $ do writeTChan clientReverseRequests payload
      Just ty      -> assertFailure $ "handleServerTestDAP: Unsupported message type: " ++ show ty
      Nothing      -> assertFailure $ "Received message without type: " ++ show payload
  where
    nextPayload = do
      TestDAPClientContext{clientHandle = h} <- ask
      payload <- liftIO $ readPayload h
      case payload of
        Left e -> fail e
        Right actual -> pure actual

    parseType = withObject "message" $ \o -> do
      typ <- o .: "type"
      pure ((typ :: String))

    -- Returns an error message when success is False, Nothing when success is True.
    parseSuccess = withObject "response" $ \o -> do
      success <- o .: "success"
      if success
        then fail "success"
        else do
          msg <- o .:? "message" .!= "DAP response had success: false (no message)"
          pure (msg :: String)