packages feed

taffybar-7.2.1: test/lib/System/Taffybar/Test/UtilSpec.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ViewPatterns #-}

module System.Taffybar.Test.UtilSpec
  ( spec,

    -- * Mock commands
    withMockCommand,
    writeScript,

    -- * Environment setup
    withEnv,
    withSetEnv,
    prependPath,

    -- ** Running subprocesses
    withService,
    setStdoutCond,
    setStderrCond,
    setServiceDefaults,
    makeServiceDefaults,

    -- * Concurrency
    listLiveThreads,
    diffLiveThreads,

    -- * OS Resources
    listFds,

    -- * Other test helpers
    tryIOMaybe,
    laxTimeout,
    laxTimeout',
    DodgyEq (..),

    -- ** Logging for tests
    logSetup,
    specLogSetup,
    specLogSetupPrio,
    specLog,
    specLogAt,
    getSpecLogPriority,
    Priority (..),
  )
where

import Control.Applicative ((<|>))
import Control.Monad (guard, join, void, (<=<))
import Control.Monad.IO.Unlift
import Data.Bifunctor (second)
import Data.ByteString.Char8 qualified as B8
import Data.Either.Extra (eitherToMaybe, isLeft)
import Data.Function (on, (&))
import Data.List (deleteFirstsBy, uncons)
import Data.Maybe (catMaybes, fromMaybe)
#if MIN_VERSION_base(4,18,0)
import GHC.Conc.Sync (ThreadId(..), ThreadStatus(..), listThreads, threadStatus, threadLabel)
#else
import GHC.Conc.Sync (ThreadId(..), ThreadStatus(..))
#endif
import System.Exit (ExitCode (..))
import System.FilePath (isRelative, takeFileName, (</>))
import System.IO (BufferMode (..), Handle, hClose, hSetBuffering, stderr)
import System.Log.Handler.Simple (GenericHandler (..))
import System.Log.Logger (Priority (..), getLevel, getLogger, logM, removeHandler, setHandlers, setLevel, updateGlobalLogger)
import System.Posix.Files (readSymbolicLink)
import System.Process.Typed (ExitCodeException (..), Process, ProcessConfig, StreamSpec, createPipe, getStdin, inherit, nullStream, proc, readProcess, setCloseFds, setStderr, setStdin, setStdout, waitExitCode, withProcessTerm)
import System.Taffybar.LogFormatter (taffyLogHandler)
import Test.Hspec
import Text.Printf (printf)
import Text.Read (readMaybe)
import UnliftIO.Async (race)
import UnliftIO.Concurrent (forkFinally, threadDelay)
import UnliftIO.Directory (Permissions (..), findExecutable, getPermissions, listDirectory, setPermissions)
import UnliftIO.Environment (lookupEnv, setEnv, unsetEnv)
import UnliftIO.Exception (StringException (..), bracket, evaluateDeep, throwIO, throwString, try, tryIO)
import UnliftIO.MVar qualified as MV
import UnliftIO.Temporary (withSystemTempDirectory)
import UnliftIO.Timeout (timeout)

-- | Run the given 'IO' action with the @PATH@ environment variable
-- set up so that executing the given command name will run a
-- script.
withMockCommand ::
  -- | Name of command - should not contain slashes
  FilePath ->
  -- | Contents of script
  String ->
  -- | Action to run with command available in search path
  IO a ->
  IO a
withMockCommand name content action =
  withSystemTempDirectory "specutil" $ \dir -> do
    writeScript (dir </> takeFileName name) content
    withEnv [("PATH", prependPath dir)] action

-- | Write a text file, make it executable.
-- It ought to have a shebang line.
writeScript :: FilePath -> String -> IO ()
writeScript scriptFile content = do
  content' <- patchShebangs content
  writeFile scriptFile content'
  p <- getPermissions scriptFile
  setPermissions scriptFile (p {executable = True})

-- | Given the text of a shell script, this replaces any relative path
-- in the shebang with an absolute path, according to the current
-- environment's @PATH@ variable.
--
-- The only reason this exists is so that we can generate shell
-- scripts containing @#!/usr/bin/env bash@ and then be able to
-- execute them within a Nix build sandbox (which does not allow
-- @/usr/bin/env@).
patchShebangs :: String -> IO String
patchShebangs = patchShebangs' findExe
  where
    findExe = fmap join . traverse findExecutable . takeRelativeFileName

    takeRelativeFileName :: FilePath -> Maybe FilePath
    takeRelativeFileName fp = guard (isRelative fp) >> pure (takeFileName fp)

patchShebangs' :: (Applicative m) => (FilePath -> m (Maybe FilePath)) -> String -> m String
patchShebangs' replaceExe script = case parseInterpreter script of
  Just (interpreter, rest) -> do
    let unparse exe = "#! " ++ exe ++ rest
    maybe script unparse <$> replaceExe interpreter
  Nothing -> pure script

parseInterpreter :: String -> Maybe (String, String)
parseInterpreter (lines -> content) = do
  (header, rest) <- uncons content
  (interpreter, args) <- parseShebang header
  pure (interpreter, unlines (args : rest))
  where
    parseShebang :: String -> Maybe (String, String)
    parseShebang ('#' : '!' : (findInterpreter -> shebang)) =
      let catArgs args = unwords ("" : args)
       in second catArgs <$> shebang
    parseShebang _ = Nothing

    findInterpreter = uncons . dropWhile ((== "env") . takeFileName) . words

-- | Run an 'IO' action with the given environment variables set up
-- according to their current value. 'Nothing' denotes an unset
-- environment variable. After the 'IO' action completes, environment
-- variables are restored to their previous state.
withEnv :: [(String, Maybe String -> Maybe String)] -> IO a -> IO a
withEnv mods = bracket setup teardown . const
  where
    setup = mapM (uncurry changeEnv) mods
    teardown = mapM (uncurry putEnv) . reverse

    changeEnv name f = do
      old <- lookupEnv name
      putEnv name (f old)
      pure (name, old)

    putEnv :: String -> Maybe String -> IO ()
    putEnv name = maybe (unsetEnv name) (setEnv name)

withSetEnv :: [(String, String)] -> IO a -> IO a
withSetEnv = withEnv . map (second (const . Just))

-- | Use this as a modifier function argument of 'withEnv' to ensure
-- that the given directory is prepended to a search path variable.
prependPath :: FilePath -> Maybe String -> Maybe String
prependPath p = Just . (++ ":/usr/bin") . (p ++) . maybe "" (":" ++)

listFds :: (MonadIO m) => m [(Int, FilePath)]
listFds = catMaybes <$> (listDirectory fdPath >>= mapM readEntry)
  where
    fdPath = "/proc/self/fd"

    readEntry :: (MonadIO m) => FilePath -> m (Maybe (Int, FilePath))
    readEntry fd = do
      t <- liftIO $ tryIOMaybe $ readSymbolicLink (fdPath </> fd)
      pure $ (,) <$> readMaybe fd <*> t

listLiveThreads :: IO [(ThreadId, (String, Maybe ThreadStatus))]
#if MIN_VERSION_base(4,18,0)
listLiveThreads = do
  threadIds <- listThreads
  labels <- mapM (fmap (fromMaybe "" . join) . tryIOMaybe . threadLabel) threadIds
  statuses <- mapM (tryIOMaybe . threadStatus) threadIds
  let isAlive s = s /= ThreadFinished && s /= ThreadDied
  pure $ filter (maybe True isAlive . snd . snd) $ zip threadIds (zip labels statuses)
#else
listLiveThreads = pure []
#endif

diffLiveThreads :: (Eq a) => [(a, b)] -> [(a, b)] -> [(a, b)]
diffLiveThreads = deleteFirstsBy ((==) `on` fst)

tryIOMaybe :: (MonadUnliftIO m) => m a -> m (Maybe a)
tryIOMaybe = fmap eitherToMaybe . tryIO

laxTimeout' :: (HasCallStack, MonadUnliftIO m) => Int -> m a -> m a
laxTimeout' n action =
  laxTimeout n action >>= \case
    Just a -> pure a
    Nothing -> expectationFailure' $ printf "Timed out after %dusec" n

expectationFailure' :: (HasCallStack, MonadIO m) => String -> m a
expectationFailure' msg = liftIO (expectationFailure msg) >> throwString msg

laxTimeout :: (HasCallStack, MonadUnliftIO m) => Int -> m a -> m (Maybe a)
laxTimeout n action = do
  result <- MV.newEmptyMVar
  void $ forkFinally (timeout n action) (MV.putMVar result)
  join <$> timeout n (MV.takeMVar result >>= either throwIO pure)

-- | A wrapper to provide 'Eq' for types which only have 'Show'.
newtype DodgyEq a = DodgyEq {unDodgyEq :: a}
  deriving (Show) via (DodgyEq a)

instance Eq (DodgyEq a) where
  a == b = show a == show b

------------------------------------------------------------------------

-- | Logger name for messages originating from specs.
specLoggerName :: String
specLoggerName = "Test"

-- | Log a test message.
specLog :: (MonadIO m) => String -> m ()
specLog = specLogAt INFO

-- | Log a test message at the given level.
specLogAt :: (MonadIO m) => Priority -> String -> m ()
specLogAt level = liftIO . logM specLoggerName level

-- | Setup logging before running the specs.
logSetup :: (HasCallStack) => SpecWith a -> SpecWith a
logSetup = beforeAll_ specLogSetup

-- | Get log levels from environment variables and set up formatters.
specLogSetup :: IO ()
specLogSetup = specLogSetupPrio WARNING

-- | Like 'specLogSetup', but with a default minimum priority.
specLogSetupPrio :: Priority -> IO ()
specLogSetupPrio defaultPriority = do
  updateGlobalLogger "" removeHandler
  hSetBuffering stderr LineBuffering
  setup "System.Taffybar" "TAFFYBAR_VERBOSE" taffyLogHandler
  setup specLoggerName "TAFFYBAR_TEST_VERBOSE" (pure specLogHandler)
  where
    setup loggerName envVar getHandler = do
      p <- fromMaybe defaultPriority <$> getEnvPriority envVar
      h <- getHandler
      updateGlobalLogger loggerName (setLevel p . setHandlers [h])

-- | A plain looking log handler, to contrast with 'taffyLogFormatter'.
specLogHandler :: GenericHandler Handle
specLogHandler =
  GenericHandler
    { priority = DEBUG,
      formatter = \_ (level, msg) _name -> return (show level ++ ": " ++ msg),
      privData = stderr,
      writeFunc = \h -> B8.hPutStrLn h . B8.pack <=< evaluateDeep,
      closeFunc = \_ -> return ()
    }

-- | Find out the configured log level for specs.
getSpecLogPriority :: (MonadIO m) => m Priority
getSpecLogPriority = fromMaybe WARNING . getLevel <$> liftIO (getLogger specLoggerName)

-- | Converts an environment variable value to a 'Priority'.
-- Numeric or textual levels are supported.
getEnvPriority :: String -> IO (Maybe Priority)
getEnvPriority = fmap (>>= toPriority) . lookupEnv
  where
    toPriority s = readMaybe s <|> fmap fromInt (readMaybe s)

    fromInt :: Int -> Priority
    fromInt n
      | n >= 2 = DEBUG
      | n <= 0 = WARNING
      | otherwise = INFO

-- | Like 'withProcessTerm_', except that if the process exits -- for
-- whatever reason -- before the action completes, then it's an
-- error. It will immediately cancel the action and throw an
-- 'ExitCodeException'.
withService :: (MonadUnliftIO m) => ProcessConfig stdin stdout stderr -> (Process stdin stdout stderr -> m a) -> m a
withService cfg action = withProcessTerm cfg $ \p -> do
  either throwEarlyExitException pure =<< race (waitExitCode p) (action p)
  where
    throwEarlyExitException c = throwIO $ ExitCodeException c cfg' "" ""
    cfg' = cfg & setStdin nullStream & setStdout nullStream & setStderr nullStream

streamSpecCond :: Priority -> Priority -> StreamSpec any ()
streamSpecCond level verbosity = if level >= verbosity then inherit else nullStream

setStdoutCond :: Priority -> ProcessConfig i o e -> ProcessConfig i () e
setStdoutCond = setStdout . streamSpecCond DEBUG

setStderrCond :: Priority -> ProcessConfig i o e -> ProcessConfig i o ()
setStderrCond = setStderr . streamSpecCond INFO

makeServiceDefaults :: FilePath -> [String] -> IO (ProcessConfig () () ())
makeServiceDefaults prog args =
  flip setServiceDefaults (proc prog args) <$> getSpecLogPriority

setServiceDefaults :: Priority -> ProcessConfig i o e -> ProcessConfig () () ()
setServiceDefaults logLevel =
  setCloseFds True
    . setStdin nullStream
    . setStdoutCond logLevel
    . setStderrCond logLevel

------------------------------------------------------------------------

spec :: Spec
spec = do
  it "withMockCommand" $
    example $
      withMockCommand "blah" "#!/usr/bin/env bash\necho hello \"$@\"\n" $ do
        (code, out, err) <- readProcess (proc "blah" ["arstd"])
        code `shouldBe` ExitSuccess
        out `shouldBe` "hello arstd\n"
        err `shouldBe` ""

  it "laxTimeout" $ example $ do
    let t = 50_000
    laxTimeout t (threadDelay (t * 2)) `shouldReturn` Nothing

  describe "withService" $ around_ (laxTimeout' 1_000_000) $ do
    let wait = const $ threadDelay maxBound
    it "normal" $
      example $
        withService (proc "sleep" ["60"]) (const $ pure ())
          `shouldReturn` ()
    it "exc" $
      example $
        withService (proc "sleep" ["60"]) (const $ throwString "hello")
          `shouldThrow` \(StringException msg _) -> msg == "hello"
    it "early exit" $
      example $
        withService "true" wait
          `shouldThrow` \exc -> eceExitCode exc == ExitSuccess
    it "manual exit" $
      example $
        withService
          (proc "cat" [] & setStdin createPipe)
          (\p -> hClose (getStdin p) >> wait p)
          `shouldThrow` \exc -> eceExitCode exc == ExitSuccess
    it "failure" $
      example $
        withService "false" wait
          `shouldThrow` \exc -> eceExitCode exc /= ExitSuccess
    it "error message" $ example $ do
      res <- try (withService (proc "false" ["arg1", "arg2"]) wait)
      res `shouldSatisfy` isLeft
      either (show . eceProcessConfig) show res
        `shouldBe` "Raw command: false arg1 arg2\n"