packages feed

webdriver-0.13.0.0: src/Test/WebDriver/Commands/Logs/BiDi.hs

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ViewPatterns #-}

module Test.WebDriver.Commands.Logs.BiDi (
  withRecordLogsViaBiDi
  , withRecordLogsViaBiDi'
  ) where

import Control.Concurrent.STM (retry)
import Control.Monad (forever)
import Control.Monad.Fix (fix)
import Control.Monad.IO.Unlift
import Control.Monad.Logger (MonadLogger, logDebugN, logErrorN, logInfoN, logWarnN)
import Data.Aeson
import Data.Aeson.TH
import Data.Aeson.Types (parseEither)
import qualified Data.List as L
import Data.String.Interpolate
import Data.Text (Text)
import qualified Network.URI as URI
import qualified Network.WebSockets as WS
import Test.WebDriver.Capabilities.Aeson
import Test.WebDriver.Commands.Logs.Common
import Test.WebDriver.Types
import Text.Read (readMaybe)
import UnliftIO.Async (withAsync)
import UnliftIO.Exception
import UnliftIO.STM (atomically, newTVarIO, readTVar, writeTVar)
import UnliftIO.Timeout (timeout)


data BiDiLogEvent = BiDiLogEvent {
  biDiType :: Text
  , biDiMethod :: Text
  , biDiParams :: Value
  } deriving Show
deriveFromJSON toCamel2 ''BiDiLogEvent

data BiDiResponse = BiDiResponse {
  biDiResponseType :: Text
  , biDiResponseId :: Int
  , biDiResponseResult :: Maybe Value
  , biDiResponseError :: Maybe Value
  } deriving Show
deriveFromJSON toCamel3 ''BiDiResponse

-- | Wrapper around 'withRecordLogsViaBiDi'' which uses the WebSocket URL from
-- the current 'Session'. You must make sure to pass '_capabilitiesWebSocketUrl'
-- = @Just True@ to enable this. This will not work with Selenium 3.
withRecordLogsViaBiDi :: (WebDriver m, MonadLogger m) => (LogEntry -> m ()) -> m a -> m a
withRecordLogsViaBiDi cb action = do
  Session {..} <- getSession
  webSocketUrl <- case sessionWebSocketUrl of
    Nothing -> throwIO $ userError [i|Session wasn't configured with a BiDi WebSocket URL when trying to record logs. Make sure to enable _capabilitiesWebSocketUrl.|]
    Just x -> pure x

  uri <- case URI.parseURI webSocketUrl of
    Just x -> pure x
    Nothing -> throwIO $ userError [i|Couldn't parse WebSocket URL: #{webSocketUrl}|]

  withRecordLogsViaBiDi' uri cb action

-- | Connect to WebSocket URL and subscribe to log events using the W3C BiDi protocol; see
-- <https://w3c.github.io/webdriver-bidi/>.
withRecordLogsViaBiDi' :: (MonadUnliftIO m, MonadLogger m) => URI.URI -> (LogEntry -> m ()) -> m a -> m a
withRecordLogsViaBiDi' uri@(URI.URI { uriAuthority=(Just (URI.URIAuth {uriPort=(readMaybe . L.drop 1 -> Just (port :: Int)), ..})), .. }) cb action = do
  subscriptionStatus <- newTVarIO Nothing

  withAsync (backgroundAction subscriptionStatus) $ \_ -> do
    -- Wait for subscription to be confirmed or errored before proceeding
    result <- atomically $ do
      status <- readTVar subscriptionStatus
      case status of
        Nothing -> retry
        Just (Left err) -> pure (Left err)
        Just (Right ()) -> pure (Right ())

    case result of
      Left err -> throwIO err
      Right () -> action

  where
    backgroundAction subscriptionStatus = withRunInIO $ \runInIO -> do
      result <- tryAny $ do
        runInIO $ logDebugN [i|BiDi: Connecting to #{uriRegName}:#{port}#{uriPath}|]

        liftIO $ WS.runClient uriRegName port uriPath $ \conn -> do
          runInIO $ logInfoN "BiDi: Connected successfully"

          -- Send subscription request for console logs
          runInIO $ logDebugN "BiDi: Sending subscription request"
          WS.sendTextData conn $ encode $ object [
            "id" .= (1 :: Int)
            , "method" .= ("session.subscribe" :: Text)
            , "params" .= object [
                "events" .= (["log.entryAdded"] :: [Text])
              ]
            ]
          runInIO $ logDebugN "BiDi: Sent subscription request, waiting for response..."

          subscriptionResult <- timeout 15_000_000 $ fix $ \loop -> do
            msg <- WS.receiveData conn
            runInIO $ logDebugN [i|BiDi: Waiting for subscription response: #{msg}|]
            case decode msg of
              Just response@(BiDiResponse responseType responseId _ _)
                | responseType == "success" && responseId == 1 -> do
                    runInIO $ logInfoN "BiDi: Subscription successful!"
                    atomically $ writeTVar subscriptionStatus (Just (Right ()))
                | responseType == "error" && responseId == 1 -> do
                    let errMsg = "BiDi subscription failed: " ++ show response
                    runInIO $ logErrorN [i|BiDi: #{errMsg}|]
                    atomically $ writeTVar subscriptionStatus (Just (Left (toException (userError errMsg))))
                | otherwise -> do
                    runInIO $ logDebugN [i|BiDi: Ignoring response with type #{responseType}, ID #{responseId}|]
                    loop
              Nothing -> do
                runInIO $ logDebugN [i|BiDi: Not a BiDiResponse, continuing to wait for subscription response (#{msg})|]
                loop

          case subscriptionResult of
            Nothing -> do
              runInIO $ logErrorN "BiDi: Subscription response timed out"
              atomically $ writeTVar subscriptionStatus (Just (Left (toException (userError "BiDi subscription response timed out"))))
            Just () -> do
              runInIO $ logDebugN "BiDi: Starting log event listener"
              forever $ do
                msg <- WS.receiveData conn
                runInIO $ logDebugN [i|BiDi: Received log event: #{msg}|]
                case decode msg of
                  Just (BiDiLogEvent "event" "log.entryAdded" params) ->
                    case parseBiDiLogEntry params of
                      Just logEntry -> runInIO (cb logEntry)
                      Nothing -> runInIO $ logWarnN "BiDi: Failed to parse log entry"
                  _ -> runInIO $ logDebugN [i|BiDi: Ignoring non-log event message: #{msg}|]

      case result of
        Left ex -> do
          runInIO $ logErrorN [i|BiDi: got exception (URI #{uri}): #{ex}|]
          atomically $ writeTVar subscriptionStatus (Just (Left ex))
        Right () -> pure ()
withRecordLogsViaBiDi' uri _cb _action =
  throwIO $ userError [i|WebSocket URL didn't contain an authority: #{uri}|]

-- | Convert BiDi log parameters to LogEntry
parseBiDiLogEntry :: Value -> Maybe LogEntry
parseBiDiLogEntry = \case
  Object o -> case parseEither parseLogEntry o of
    Right entry -> Just entry
    Left _ -> Nothing
  _ -> Nothing
  where
    parseLogEntry o = do
      timestamp <- o .: "timestamp"
      levelText <- o .: "level"
      message <- o .: "text"
      level <- case parseLogLevel levelText of
        Just l -> pure l
        Nothing -> fail "Invalid log level"
      pure $ LogEntry (round (timestamp :: Double)) level message

    parseLogLevel :: Text -> Maybe LogLevel
    parseLogLevel = \case
      "debug" -> Just LogDebug
      "info" -> Just LogInfo
      "warn" -> Just LogWarning
      "error" -> Just LogSevere
      _ -> Nothing