packages feed

tricorder-0.5.0.0: src/Tricorder/Daemon/TestRunner.hs

module Tricorder.Daemon.TestRunner
    ( -- * Effect
      TestRunner (..)
    , runTestSuite

      -- * Interpreters
    , run
    , runScripted

      -- * Parsing utilities
    , GhciOutcome (..)
    , detectOutcome

      -- * Internal helpers (exported for testing)
    , loadingToProgress
    )
where

import Atelier.Effects.Conc (Conc)
import Atelier.Effects.File (File)
import Atelier.Effects.Log (Log)
import Atelier.Effects.Process
    ( Process
    , createPipe
    , getStderr
    , getStdin
    , getStdout
    , setStderr
    , setStdin
    , setStdout
    , setWorkingDir
    , shell
    )
import Atelier.Effects.Timeout (Timeout, timeout)
import Control.Concurrent.STM (modifyTVar')
import Control.Exception (throwIO)
import Data.Default (def)
import Data.Sequence ((|>))
import Data.Time.Units (Second)
import Effectful (Effect, IOE, Limit (..), Persistence (..), UnliftStrategy (ConcUnlift))
import Effectful.Concurrent (Concurrent)
import Effectful.Concurrent.STM (atomically, newTVarIO, readTVarIO)
import Effectful.Dispatch.Dynamic (interpretWith, localUnlift, reinterpret_)
import Effectful.Exception (trySync)
import Effectful.Reader.Static (Reader, ask)
import Effectful.State.Static.Shared (State, evalState, get, put)
import Effectful.TH (makeEffect)
import System.Exit (ExitCode (..))

import Atelier.Effects.Conc qualified as Conc
import Atelier.Effects.File qualified as File
import Atelier.Effects.Process qualified as Process
import Data.List qualified as List
import Data.Text qualified as T

import Tricorder.Daemon.GhciSession.GhciParser (GhciLoading (..), parseProgressLine)
import Tricorder.Daemon.GhciSession.GhciProcess
    ( GhciProcessError (..)
    , UnexpectedExit (..)
    , execGhci
    , withGhciProcess
    )
import Tricorder.Runtime (ProjectRoot (..))
import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))
import Tricorder.Session.Stage (Stage (..))
import Tricorder.Session.Stage.Test.Command (RenderedTestCommand (..))
import Tricorder.Session.Stage.Test.Config (OutputMode (..))
import Tricorder.Session.Stage.Test.Session (ResolvedTestOptions (..))
import Tricorder.Session.TestTimeout (TestTimeout (..))
import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput)

import Tricorder.Build.Test qualified as Test


data TestRunner :: Effect where
    -- | Run a single test suite in a short-lived @cabal repl@ process and
    -- return the captured output and detected outcome.
    RunTestSuite
        :: (Test.Suite -> m ())
        -- ^ Handler for test run progress
        -> TestTimeout
        -> RenderedTestCommand
        -> TestRunner m Test.Suite


makeEffect ''TestRunner


-- | Production interpreter that spawns a short-lived @cabal repl test:\<name\>@
-- process for each suite, feeds @:main\\n:quit\\n@ to stdin, captures combined
-- stdout+stderr, and detects the outcome via 'detectOutcome'.
run
    :: ( Conc :> es
       , Concurrent :> es
       , File :> es
       , Log :> es
       , Process :> es
       , Reader ProjectRoot :> es
       , Timeout :> es
       )
    => Eff (TestRunner : es) a -> Eff es a
run act = do
    interpretWith act \env -> \case
        RunTestSuite progressHandler testTimeout cmd ->
            localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do
                let onProgress = unlift . progressHandler . loadingToProgress
                case cmd.options.outputMode of
                    ReplOutput -> runTestSuiteWithGHCi onProgress testTimeout cmd.command
                    StdoutOutput -> runTestSuiteWithStdout onProgress testTimeout cmd.command


runTestSuiteWithStdout
    :: ( Conc :> es
       , Concurrent :> es
       , File :> es
       , Process :> es
       , Reader ProjectRoot :> es
       , Timeout :> es
       )
    => (GhciLoading -> Eff es ())
    -> TestTimeout
    -> RenderedCommand 'Test
    -> Eff es Test.Suite
runTestSuiteWithStdout onProgress testTimeout cmd = do
    ProjectRoot projectRoot <- ask
    -- Lines from both streams, in the order they arrived.
    outputVar <- newTVarIO (mempty :: Seq Text)
    let processConfig =
            setStdin createPipe
                $ setStdout createPipe
                $ setStderr createPipe
                $ setWorkingDir projectRoot
                $ shell
                $ toString cmd.getRenderedCommand

        drain h =
            unlessM (File.hIsEOF h) do
                line <- File.hGetLine h
                atomically $ modifyTVar' outputVar (|> line)
                traverse_ onProgress (parseProgressLine line)
                drain h

        runToCompletion p = do
            -- The suite gets no input; close stdin so it sees EOF if it reads.
            File.hClose (getStdin p)
            Conc.scoped do
                stdoutThread <- Conc.fork $ drain (getStdout p)
                stderrThread <- Conc.fork $ drain (getStderr p)
                Conc.await stdoutThread
                Conc.await stderrThread
            Process.waitExitCode p

    result <- trySync
        $ Process.withProcessGroup processConfig \p ->
            case testTimeout of
                TestTimeout secs | secs <= 0 -> Right <$> runToCompletion p
                TestTimeout secs ->
                    let duration = fromIntegral secs :: Second
                    in  maybeToRight secs <$> timeout duration (runToCompletion p)
    output <- T.unlines . toList <$> readTVarIO outputVar
    let completed passed =
            Test.SuiteCompleted
                $ Test.SuiteCompletion
                    { passed
                    , output
                    , testCases = parseHspecOutput output
                    , duration = parseHspecDuration output
                    }
    pure $ case result of
        Left ex ->
            Test.SuiteErrored
                $ Test.SuiteError
                    { message = "Test suite failed to run:\n" <> show ex
                    }
        Right (Left secs) ->
            Test.SuiteErrored
                $ Test.SuiteError
                    { message = "Test suite timed out after " <> show secs <> "s"
                    }
        Right (Right ExitSuccess) -> completed True
        Right (Right (ExitFailure _)) -> case detectOutcome output of
            GhciCrashed msg -> Test.SuiteErrored $ Test.SuiteError {message = msg}
            _ -> completed False


runTestSuiteWithGHCi
    :: ( Conc :> es
       , Concurrent :> es
       , File :> es
       , Log :> es
       , Process :> es
       , Reader ProjectRoot :> es
       , Timeout :> es
       )
    => (GhciLoading -> Eff es ())
    -> TestTimeout
    -> RenderedCommand 'Test
    -> Eff es Test.Suite
runTestSuiteWithGHCi onProgress testTimeout cmd = do
    ProjectRoot projectRoot <- ask
    result <- trySync
        $ withGhciProcess
            def
            cmd
            projectRoot
            onProgress
            noReady
            \ghci _ ->
                case testTimeout of
                    TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress
                    TestTimeout secs ->
                        let duration = fromIntegral secs :: Second
                        in  maybeToRight secs
                                <$> timeout duration (execGhci ghci ":main" noProgress)
    case result of
        Left ex -> case fromException ex of
            Just (e :: GhciProcessError) -> case e of
                UnexpectedExit (MkUnexpectedExit marker msg) ->
                    pure
                        $ Test.SuiteErrored
                        $ Test.SuiteError
                            { message = "Test suite failed while waiting for marker (" <> marker <> "):\n" <> msg
                            }
                StartupTimeout -> pure $ Test.SuiteErrored $ Test.SuiteError "Test suite timed out before it could finish"
                StartupFailed msg ->
                    pure
                        $ Test.SuiteErrored
                        $ Test.SuiteError
                        $ "Test suite failed on startup, before Tricorder could start running the test suite itself:\n" <> msg
            Nothing ->
                pure
                    $ Test.SuiteErrored
                    $ Test.SuiteError {message = show ex}
        Right (Left secs) -> do
            pure
                $ Test.SuiteErrored
                $ Test.SuiteError
                    { message = "Test suite timed out after " <> show secs <> "s"
                    }
        Right (Right mainLines) ->
            pure
                $ let output = T.unlines mainLines
                  in  case detectOutcome output of
                        GhciCrashed msg ->
                            Test.SuiteErrored $ Test.SuiteError {message = msg}
                        outcome ->
                            Test.SuiteCompleted
                                $ Test.SuiteCompletion
                                    { passed = outcome == GhciPassed
                                    , output
                                    , testCases = parseHspecOutput output
                                    , duration = parseHspecDuration output
                                    }
  where
    noReady _ = pure ()
    noProgress _ = pure ()


-- | Scripted interpreter for testing.
--
-- Each call to 'runTestSuite' pops the next result from the pre-loaded list.
-- 'Left' results are re-thrown as exceptions, simulating process failures.
runScripted
    :: forall es a
     . (IOE :> es)
    => [Either SomeException Test.Suite]
    -> Eff (TestRunner : es) a
    -> Eff es a
runScripted results =
    reinterpret_
        (evalState results)
        (\(RunTestSuite _ _ _) -> popResult)
  where
    popResult :: Eff (State [Either SomeException Test.Suite] : es) Test.Suite
    popResult =
        get >>= \case
            [] -> error "TestRunnerScripted: no more results in queue"
            Left ex : rest -> put rest >> liftIO (throwIO ex)
            Right r : rest -> put rest >> pure r


loadingToProgress :: GhciLoading -> Test.Suite
loadingToProgress loading =
    Test.SuiteRunning
        $ Just
        $ Test.Progress {compiled = loading.index, total = loading.total}


data GhciOutcome
    = GhciPassed
    | GhciFailed
    | GhciCrashed Text
    deriving stock (Eq, Show)


-- | Detect the test outcome from raw GHCi output.
--
-- All major test frameworks (@hspec@, @tasty@, @HUnit@) call
-- 'System.Exit.exitWith' on completion. GHCi surfaces this as a line
-- matching @*** Exception: ExitSuccess@ (pass) or
-- @*** Exception: ExitFailure N@ (fail). Any other @*** Exception:@ line
-- means the runner crashed.
--
-- When no exception line is present, the absence is ambiguous: either the
-- test ran and printed nothing exit-related, or @:main@ never ran at all
-- (e.g. the test target failed to compile, so @main@ is not in scope).
-- A line containing @": error:"@ in the captured output is treated as the
-- latter — a GHC compile/load error that prevented the suite from running.
detectOutcome :: Text -> GhciOutcome
detectOutcome output =
    case List.find ("*** Exception: " `T.isPrefixOf`) outputLines of
        Just line ->
            case T.stripPrefix "*** Exception: " line of
                Nothing -> GhciPassed
                Just rest ->
                    let r = T.strip rest
                    in  if r == "ExitSuccess"
                            then
                                GhciPassed
                            else
                                if "ExitFailure" `T.isPrefixOf` r
                                    then
                                        GhciFailed
                                    else
                                        GhciCrashed r
        Nothing -> case List.find isCompileErrorLine outputLines of
            Just errLine -> GhciCrashed (T.strip errLine)
            Nothing -> GhciPassed
  where
    outputLines = T.lines output
    -- GHC compile/load errors are formatted as
    -- @<file-or-loc>:L:C: error: …@ (with at least one space after the colon).
    -- The substring @": error:"@ is the canonical marker for these.
    isCompileErrorLine line = ": error:" `T.isInfixOf` line