packages feed

sandwich-slack-0.2.0.0: src/Test/Sandwich/Formatters/Slack.hs

{-# LANGUAGE OverloadedLists #-}

-- | The Slack formatter shows live-updating test progress and failures by sending messages to a Slack channel.
--
-- Documentation can be found <https://codedownio.github.io/sandwich/docs/formatters/slack here>.

module Test.Sandwich.Formatters.Slack (
  defaultSlackFormatter
  , SlackFormatter

  -- * Options
  , slackFormatterSlackConfig
  , slackFormatterChannel

  , slackFormatterTopMessage

  , slackFormatterMaxFailures
  , slackFormatterMaxFailureReasonLines
  , slackFormatterMaxCallStackLines

  , slackFormatterVisibilityThreshold

  , slackFormatterMaxMessageSize

  -- * Auxiliary types
  , SlackConfig(..)
  , SlackFormatterShowCallStacks(..)
  ) where

import Control.Applicative
import Control.Concurrent
import Control.Concurrent.STM
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.IO.Unlift
import Control.Monad.Logger hiding (logError)
import qualified Data.Aeson as A
import Data.Foldable
import Data.Function
import qualified Data.List as L
import qualified Data.Map as M
import Data.Maybe
import Data.String.Interpolate
import qualified Data.Text as T
import Data.Time
import GHC.Int
import Safe
import Test.Sandwich
import Test.Sandwich.Formatters.Slack.Internal.Markdown
import Test.Sandwich.Formatters.Slack.Internal.ProgressBar
import Test.Sandwich.Formatters.Slack.Internal.Types
import Test.Sandwich.Internal
import UnliftIO.Exception


data SlackFormatter = SlackFormatter {
  slackFormatterSlackConfig :: SlackConfig
  -- ^ Slack credentials
  , slackFormatterChannel :: String
  -- ^ Slack channel on which to create the progress bar.

  , slackFormatterTopMessage :: Maybe String
  -- ^ Message to put above the progress bar.
  -- For example, the name of the test suite and a link to the run in the CI system.

  , slackFormatterMaxFailures :: Maybe Int
  -- ^ Maximum number of failures to include in a message.
  -- If too many are included, it's possible to hit Slack's request limit of 8KB, which
  -- causes the message to fail to update.
  -- Defaults to 30.
  , slackFormatterMaxFailureReasonLines :: Maybe Int
  -- ^ Maximum number of lines to devote to showing the failure reason underneath a failure.
  -- Set to 'Just 0' to disable showing failure reasons.
  , slackFormatterMaxCallStackLines :: Maybe Int
  -- ^ Maximum number of lines to devote to showing the call stack underneath a failure.
  -- Set to 'Just 0' to disable showing call stacks.

  , slackFormatterVisibilityThreshold :: Maybe Int
  -- ^ If present, filter the headings on failures to only include nodes whose visibility
  -- threshold is less than or equal to the value.

  , slackFormatterMaxMessageSize :: Maybe Int64
  -- ^ If present, make sure the messages we transmit to Slack default don't exceed this number
  -- of bytes. When a message does exceed it (probably because there are a ton of failures),
  -- start dropping blocks from the end of the message until the size is small enough.
  -- Making use of 'slackFormatterMaxFailures', 'slackFormatterMaxFailureReasonLines', and
  -- 'slackFormatterMaxCallStackLines' is a good way to avoid hitting the limit.
  } deriving (Show)

defaultSlackFormatter :: SlackFormatter
defaultSlackFormatter = SlackFormatter {
  slackFormatterSlackConfig = SlackConfig "my-password"
  , slackFormatterChannel = "slack-channel"

  , slackFormatterTopMessage = Just "Top message"

  , slackFormatterMaxFailures = Just 30
  , slackFormatterMaxFailureReasonLines = Just 5
  , slackFormatterMaxCallStackLines = Just 5

  , slackFormatterVisibilityThreshold = Nothing

  -- 8KB, although Slack might accept 16KB now?
  , slackFormatterMaxMessageSize = Just 8192
  }

instance Formatter SlackFormatter where
  formatterName _ = "slack-formatter"
  runFormatter baseFormatter rts (Just clo) bc = runApp (addCommandLineOptions clo baseFormatter) rts bc
  runFormatter baseFormatter rts Nothing bc = runApp baseFormatter rts bc
  finalizeFormatter _ _ _ = return ()

addCommandLineOptions :: CommandLineOptions a -> SlackFormatter -> SlackFormatter
addCommandLineOptions (CommandLineOptions {optSlackOptions=(CommandLineSlackOptions {..})}) baseFormatter@(SlackFormatter {..}) = baseFormatter {
  slackFormatterSlackConfig = maybe slackFormatterSlackConfig (SlackConfig . T.pack) optSlackToken
  , slackFormatterChannel = fromMaybe slackFormatterChannel optSlackChannel
  , slackFormatterTopMessage = optSlackTopMessage <|> slackFormatterTopMessage
  , slackFormatterMaxFailures = optSlackMaxFailures <|> slackFormatterMaxFailures
  , slackFormatterMaxFailureReasonLines = optSlackMaxFailureReasonLines <|> slackFormatterMaxFailureReasonLines
  , slackFormatterMaxCallStackLines = optSlackMaxCallStackLines <|> slackFormatterMaxCallStackLines
  , slackFormatterVisibilityThreshold = optSlackVisibilityThreshold <|> slackFormatterVisibilityThreshold
  , slackFormatterMaxMessageSize = optSlackMaxMessageSize <|> slackFormatterMaxMessageSize
  }

runApp :: (MonadUnliftIO m, MonadLogger m) => SlackFormatter -> [RunNode BaseContext] -> BaseContext -> m ()
runApp sf@(SlackFormatter {..}) rts _bc = do
  startTime <- liftIO getCurrentTime

  let extractFromNode node = let RunNodeCommonWithStatus {..} = runNodeCommon node in (runTreeId, (T.pack runTreeLabel, runTreeVisibilityLevel))
  let idToLabelAndVisibilityThreshold = M.fromList $ mconcat [extractValues extractFromNode node | node <- rts]

  rtsFixed <- liftIO $ atomically $ mapM fixRunTree rts
  let pbi = publishTree sf idToLabelAndVisibilityThreshold 0 rtsFixed
  pb <- (liftIO $ createProgressBar slackFormatterSlackConfig (T.pack slackFormatterChannel) slackFormatterMaxMessageSize pbi) >>= \case
    Left err -> liftIO $ throwIO $ userError $ T.unpack err
    Right pb -> return pb

  unless (allIsDone rtsFixed) $ do
    currentFixedTree <- liftIO $ newTVarIO rtsFixed
    fix $ \loop -> do
      newFixedTree <- liftIO $ atomically $ do
        currentFixed <- readTVar currentFixedTree
        newFixed <- mapM fixRunTree rts
        when (fmap getCommons newFixed == fmap getCommons currentFixed) retry
        writeTVar currentFixedTree newFixed
        return newFixed

      now <- liftIO getCurrentTime
      let pbi' = publishTree sf idToLabelAndVisibilityThreshold (diffUTCTime now startTime) newFixedTree
      tryAny (liftIO $ updateProgressBar slackFormatterSlackConfig slackFormatterMaxMessageSize pb pbi') >>= \case
        Left err -> logError [i|Error updating progress bar: '#{err}'|]
        Right (Left err) -> logError [i|Inner error updating progress bar: '#{err}'. Blocks were '#{A.encode $ progressBarInfoBlocks pbi'}'|]
        Right (Right ()) -> return ()

      if | allIsDone newFixedTree -> do
             debug [i|All tree nodes are done, exiting!|]
             return ()
         | otherwise -> do
             liftIO $ threadDelay 100000 -- Sleep 100ms
             loop


publishTree :: SlackFormatter -> M.Map Int (T.Text, Int) -> NominalDiffTime -> [RunNodeWithStatus context Status l t] -> ProgressBarInfo
publishTree sf idToLabelAndVisibilityThreshold elapsed tree = pbi
  where
    pbi = ProgressBarInfo {
      progressBarInfoTopMessage = T.pack <$> (slackFormatterTopMessage sf)
      , progressBarInfoBottomMessage = Just fullBottomMessage
      , progressBarInfoSize = Just (100.0 * (fromIntegral (succeeded + pending' + failed) / (fromIntegral total)))
      , progressBarInfoAttachments = Nothing
      , progressBarInfoBlocks = Just $ case slackFormatterMaxFailures sf of
          Nothing -> mconcat blocks
          Just n -> case L.splitAt n blocks of
            (xs, []) -> mconcat xs
            (xs, rest) -> mconcat xs <> [extraFailuresBlock (L.length rest)]
      }

    runningMessage = headMay $ L.sort $ catMaybes $ flip concatMap tree $
      extractValues (\node -> if isRunningItBlock node then Just $ runTreeLabel $ runNodeCommon node else Nothing)

    fullBottomMessage = case runningMessage of
      Nothing -> bottomMessage
      Just t -> T.pack t <> "\n" <> bottomMessage
    bottomMessage = [i|#{succeeded} succeeded, #{failed} failed, #{pending'} pending, #{totalRunningTests} running of #{total} (#{formatNominalDiffTime elapsed} elapsed)|]

    blocks = catMaybes $ flip concatMap tree $ extractValuesControlRecurse $ \case
      -- Recurse into grouping nodes, because their failures are actually just derived from child failures
      RunNodeDescribe {} -> (True, Nothing)
      RunNodeParallel {} -> (True, Nothing)
      ((runTreeStatus . runNodeCommon) -> (Done {statusResult=(Failure (Pending {}))})) -> (False, Nothing)
      node@((runTreeStatus . runNodeCommon) -> (Done {statusResult=(Failure reason)})) | isFailedBlock node ->
        (False, Just $ singleFailureBlocks sf idToLabelAndVisibilityThreshold node reason)
      _ -> (True, Nothing)

    total = countWhere isItBlock tree
    succeeded = countWhere isSuccessItBlock tree
    pending' = countWhere isPendingItBlock tree
    failed = countWhere isFailedItBlock tree
    totalRunningTests = countWhere isRunningItBlock tree
    -- totalNotStartedTests = countWhere isNotStartedItBlock tree


singleFailureBlocks :: SlackFormatter -> M.Map Int (T.Text, Int) -> RunNodeWithStatus context s l t -> FailureReason -> [A.Value]
singleFailureBlocks sf idToLabelAndVisibilityThreshold node reason = catMaybes [
  Just $ markdownSectionWithLines [":red_circle: *" <> label <> "*"]

  -- Failure reason info
  , case (markdownLinesToShow, _overflowMarkdownLines) of
      ([], _) -> Nothing
      (toShow, []) -> Just $ markdownSectionWithLines toShow
      (toShow, overflow) -> Just $ markdownSectionWithLines $ addToLastLine toShow [i| (+ #{L.length overflow} more lines)|]

  -- Callstack info
  , case (callStackLinesToShow, _overflowCallStackLines) of
      ([], _) -> Nothing
      (toShow, []) -> Just $ markdownSectionWithLines toShow
      (toShow, overflow) -> Just $ markdownSectionWithLines $ addToLastLine toShow [i| (+ #{L.length overflow} more lines)|]
  ]
  where
    allMarkdownLines = T.lines $ toMarkdown reason
    (markdownLinesToShow, _overflowMarkdownLines) = case slackFormatterMaxFailureReasonLines sf of
      Nothing -> (allMarkdownLines, [])
      Just n -> L.splitAt n allMarkdownLines

    allCallStackLines = case failureCallStack reason of
      Just cs -> L.filter (not . T.null) $ T.lines $ callStackToMarkdown SlackFormatterFullCallStack cs
      _ -> []
    (callStackLinesToShow, _overflowCallStackLines) = case slackFormatterMaxCallStackLines sf of
      Nothing -> (allCallStackLines, [])
      Just n -> L.splitAt n allCallStackLines

    -- Show a question mark if we can't determine the label for a node (should never happen).
    -- Otherwise, use slackFormatterVisibilityThreshold to filter if provided.
    filterFn k = case M.lookup k idToLabelAndVisibilityThreshold of
      Nothing -> Just "?"
      Just (l, thresh) -> case slackFormatterVisibilityThreshold sf of
        Just maxThresh | thresh > maxThresh -> Nothing
        _ -> Just l
    label = T.intercalate ", " $ mapMaybe filterFn $ toList $ runTreeAncestors $ runNodeCommon node

extraFailuresBlock :: Int -> A.Value
extraFailuresBlock numExtraFailures = markdownSectionWithLines [[i|+ #{numExtraFailures} more failure|]]

markdownBlockWithLines :: [T.Text] -> A.Value
markdownBlockWithLines ls = A.object [("type", A.String "mrkdwn"), ("text", A.String $ T.unlines ls)]

markdownSectionWithLines :: [T.Text] -> A.Value
markdownSectionWithLines ls = A.object [("type", A.String "section"), ("text", markdownBlockWithLines ls)]

addToLastLine :: [T.Text] -> T.Text -> [T.Text]
addToLastLine [] _ = []
addToLastLine xs toAdd = (init xs) <> [last xs <> toAdd]

allIsDone :: [RunNodeFixed context] -> Bool
allIsDone = all (isDone . runTreeStatus . runNodeCommon)
  where
    isDone :: Status -> Bool
    isDone (Done {}) = True
    isDone _ = False