marionette-1.0.0: src/Test/Marionette/Client.hs
{-# LANGUAGE RoleAnnotations #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Test.Marionette.Client where
import Control.Exception (AssertionFailed (AssertionFailed))
import Control.Monad (void, when)
import Control.Monad.Catch
( Exception (..)
, MonadCatch
, MonadMask
, MonadThrow
, catch
, catchAll
, throwM
)
import Control.Monad.Error.Class (MonadError (..))
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Reader (ReaderT (runReaderT))
import Control.Monad.Reader qualified as Reader
import Control.Monad.Reader.Class (MonadReader)
import Data.Aeson (AesonException (AesonException), FromJSON)
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson
import Data.Binary (Binary)
import Data.Binary qualified as Binary
import Data.Binary.Get qualified as Binary
import Data.ByteString (ByteString)
import Data.ByteString.Builder.Extra qualified as ByteString
import Data.IntMap.Strict qualified as IntMap
import Data.Maybe (isNothing)
import GHC.Stack (HasCallStack)
import Network.Simple.TCP
( HostName
, ServiceName
, SockAddr
, Socket
, closeSock
, connectSock
, sendLazy
)
import Network.Socket.ByteString (recv)
import System.Timeout (timeout)
import Test.Marionette.Class (Marionette (..))
import Test.Marionette.Protocol
import UnliftIO
( MonadUnliftIO
, TMVar
, TQueue
, async
, bracket
, isEmptyTMVar
, link
, readTMVar
)
import UnliftIO.Concurrent (forkIO)
import UnliftIO.Retry (constantDelay, limitRetriesByCumulativeDelay, recoverAll)
import UnliftIO.STM
( atomically
, modifyTVar'
, newEmptyTMVarIO
, newTQueueIO
, newTVarIO
, putTMVar
, readTQueue
, stateTVar
, writeTQueue
)
import Prelude hiding (log)
data SocketClosed = SocketClosed
deriving stock (Show)
deriving anyclass (Exception)
newtype DecodeError = DecodeError String
deriving stock (Show)
deriving anyclass (Exception)
incoming
:: forall m a
. (MonadUnliftIO m, MonadThrow m, Binary a)
=> Socket
-> m (TQueue a)
incoming socket = do
q <- newTQueueIO
link =<< async (go (atomically . writeTQueue q) (newDecoder ""))
pure q
where
newDecoder :: ByteString -> Binary.Decoder a
newDecoder bs = Binary.runGetIncremental Binary.get `Binary.pushChunk` bs
go _ (Binary.Fail _ _ err) = throwM . DecodeError $ err
go f (Binary.Done rest _ a) = do
f a
go f $ newDecoder rest
go f dec =
go f . (dec `Binary.pushChunk`)
=<< liftIO
(recv socket ByteString.defaultChunkSize `catchAll` \_ -> throwM SocketClosed)
connect
:: (MonadUnliftIO m)
=> HostName
-> ServiceName
-> ((Socket, SockAddr) -> m a)
-> m a
connect host port =
bracket
( recoverAll (limitRetriesByCumulativeDelay 5_000_000 $ constantDelay 50_000)
. const
$ connectSock host port
)
(closeSock . fst)
newtype MarionetteTimeout = MarionetteTimeout MarionetteMessage
deriving stock (Show)
deriving anyclass (Exception)
newtype UnexpectedResult = UnexpectedResult Result
deriving stock (Show)
deriving anyclass (Exception)
data CommandWithCallback = CommandWithCallback Command (TMVar Result)
-- | A monad transformer that speaks the Marionette wire protocol over a TCP
-- socket. Run it with 'runMarionetteT'.
newtype MarionetteT m a = MarionetteT (ReaderT (TQueue CommandWithCallback) m a)
deriving newtype
( Functor
, Applicative
, Monad
, MonadThrow
, MonadCatch
, MonadMask
, MonadIO
, MonadUnliftIO
, MonadReader (TQueue CommandWithCallback)
)
instance (MonadUnliftIO m, MonadThrow m, MonadCatch m) => Marionette (MarionetteT m) where
sendCommand :: (HasCallStack, FromJSON a) => Command -> MarionetteT m a
sendCommand command = do
q <- Reader.ask
result <- newEmptyTMVarIO
atomically . writeTQueue q $ CommandWithCallback command result
either throwM pure =<< parseResult =<< atomically (readTMVar result)
where
parseResult :: (FromJSON a) => Result -> MarionetteT m (Either Error a)
parseResult =
either (pure . Left) $
either (throwM . AesonException) (pure . Right)
. Aeson.parseEither Aeson.parseJSON
instance (MonadThrow m, MonadCatch m) => MonadError Error (MarionetteT m) where
throwError = throwM
catchError = catch
-- | Connect to a Marionette server listening on @localhost:2828@ (started
-- by launching Firefox with the @--marionette@ flag), and run the given
-- action against it.
runMarionetteT
:: forall m a
. (MonadUnliftIO m, MonadMask m)
=> MarionetteT m a
-> m a
runMarionetteT action = do
sendQueue :: TQueue CommandWithCallback <- newTQueueIO
pendingCommands <- newTVarIO mempty
done <- newEmptyTMVarIO
let repeatUntilDone :: forall m'. (MonadIO m') => m' () -> m' ()
repeatUntilDone a =
atomically (isEmptyTMVar done) >>= \case
False -> pure ()
True -> a >> repeatUntilDone a
handleIncoming :: MarionetteMessage -> MarionetteT m ()
handleIncoming message =
decodeMarionetteM message >>= \Message{..} ->
atomically
( stateTVar pendingCommands $
IntMap.updateLookupWithKey (\_ _ -> Nothing) messageId
)
>>= \case
Nothing -> throwM . UnexpectedResult $ messageContent
Just result -> atomically $ putTMVar result messageContent
handleCommand :: Socket -> Int -> CommandWithCallback -> MarionetteT m ()
handleCommand socket messageId (CommandWithCallback messageContent callback) = do
let message = MarionetteMessage . Aeson.encode $ Message{..}
liftIO . sendLazy socket . Binary.encode $ message
forkIO do
result <- liftIO . timeout 5_000_000 . atomically . readTMVar $ callback
when (isNothing result) . throwM . MarionetteTimeout $ message
atomically . modifyTVar' pendingCommands $
IntMap.insert messageId callback
runSocket :: MarionetteT m ()
runSocket =
connect "localhost" "2828" \(socket, _) -> do
incomingQueue <- incoming socket
void . decodeMarionetteM @_ @Greeting =<< atomically (readTQueue incomingQueue)
let send messageId =
atomically
( isEmptyTMVar done >>= \case
True -> Just <$> readTQueue sendQueue
False -> pure Nothing
)
>>= \case
Nothing -> pure ()
Just command -> do
handleCommand socket messageId command
send $ messageId + 1
forkIO $ send 1
forkIO . repeatUntilDone $ handleIncoming =<< atomically (readTQueue incomingQueue)
atomically $ readTMVar done
flip runReaderT sendQueue . (\(MarionetteT r) -> r) $ do
forkIO runSocket
a <- action
atomically $ putTMVar done ()
pure a
where
decodeMarionetteM :: forall m' a'. (MonadThrow m', FromJSON a') => MarionetteMessage -> m' a'
decodeMarionetteM = either (throwM . AssertionFailed) pure . decodeMarionette
decodeMarionette :: forall a'. (FromJSON a') => MarionetteMessage -> Either String a'
decodeMarionette (MarionetteMessage lbs) = Aeson.eitherDecode lbs