tricorder-0.1.0.0: src/Tricorder/Effects/TestRunner.hs
module Tricorder.Effects.TestRunner
( -- * Effect
TestRunner (..)
, runTestSuite
, interruptCurrent
, resetAbort
, isAborted
-- * Interpreters
, runTestRunnerIO
, runTestRunnerScripted
-- * Parsing utilities
, GhciOutcome (..)
, detectOutcome
-- * Internal helpers (exported for testing)
, abortGatedProgress
, reportTestProgress
) where
import Atelier.Effects.Conc (Conc)
import Atelier.Effects.File (File)
import Atelier.Effects.Log (Log)
import Atelier.Effects.Process (Process)
import Atelier.Effects.Timeout (Timeout, timeout)
import Control.Concurrent.STM (TVar, readTVar, writeTVar)
import Control.Exception (throwIO)
import Data.Default (def)
import Data.Time.Units (Second)
import Effectful (Effect, IOE)
import Effectful.Concurrent (Concurrent)
import Effectful.Concurrent.STM (atomically, newTVarIO)
import Effectful.Dispatch.Dynamic (interpretWith_, reinterpret)
import Effectful.Exception (bracket_, trySync)
import Effectful.Reader.Static (Reader, ask)
import Effectful.State.Static.Shared (State, evalState, get, put)
import Effectful.TH (makeEffect)
import Atelier.Effects.Log qualified as Log
import Data.List qualified as List
import Data.Text qualified as T
import Tricorder.BuildState
( BuildPhase (..)
, BuildProgress (..)
, BuildResult (..)
, BuildState (..)
, TestRun (..)
, TestRunCompletion (..)
, TestRunError (..)
)
import Tricorder.Effects.BuildStore (BuildStore, modifyPhase)
import Tricorder.Effects.GhciSession.GhciParser (GhciLoading (..))
import Tricorder.Effects.GhciSession.GhciProcess (GhciProcess, execGhci, terminateGhciProcess, withGhciProcess)
import Tricorder.Effects.SessionStore (SessionStore)
import Tricorder.Runtime (ProjectRoot (..))
import Tricorder.Session (Session (..))
import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput)
import Tricorder.Effects.SessionStore qualified as SessionStore
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 :: Text -> TestRunner m TestRun
-- | Interrupt the test currently in flight (if any) and latch an abort
-- flag so that subsequent 'RunTestSuite' calls short-circuit until
-- 'ResetAbort' is called.
InterruptCurrent :: TestRunner m ()
-- | Clear the abort flag. Call this at the start of a new test run.
ResetAbort :: TestRunner m ()
-- | Read the abort flag without clearing it.
IsAborted :: TestRunner m Bool
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'.
runTestRunnerIO
:: ( BuildStore :> es
, Conc :> es
, Concurrent :> es
, File :> es
, Log :> es
, Process :> es
, Reader ProjectRoot :> es
, SessionStore :> es
, Timeout :> es
)
=> Eff (TestRunner : es) a -> Eff es a
runTestRunnerIO act = do
ProjectRoot projectRoot <- ask
currentProcRef <- newTVarIO (Nothing :: Maybe GhciProcess)
abortedRef <- newTVarIO False
interpretWith_ act \case
InterruptCurrent -> do
-- Terminate (not just SIGINT) the test process: hspec/tasty
-- install their own SIGINT handlers that finalise the current
-- run rather than aborting it. Each test runs in its own
-- short-lived @cabal repl@ process, so killing it outright is
-- safe and gets a prompt abort. 'execGhci' then raises
-- 'UnexpectedExit', 'trySync' catches it, and the run loop
-- short-circuits on @abortedRef@.
mProc <- atomically do
writeTVar abortedRef True
readTVar currentProcRef
for_ mProc terminateGhciProcess
ResetAbort -> atomically (writeTVar abortedRef False)
IsAborted -> atomically (readTVar abortedRef)
RunTestSuite target -> do
alreadyAborted <- atomically (readTVar abortedRef)
if alreadyAborted then
pure $ TestRunErrored $ TestRunError {target, message = "Test run aborted"}
else do
Session {testTimeout} <- SessionStore.get
let onProgress = abortGatedProgress abortedRef target
noProgress = \_ -> pure ()
-- Register the process as soon as 'startGhciProcess'
-- constructs it — before the initial @cabal repl@
-- compile drain runs. Without this, an interrupt that
-- arrives during that drain would find
-- 'currentProcRef' empty and have nothing to kill, so
-- the new build's cycle would have to wait several
-- seconds for the doomed @cabal repl@ to finish
-- compiling its test code before the cycle lock was
-- released.
onReady ghci = atomically (writeTVar currentProcRef (Just ghci))
-- Outer bracket: always clear 'currentProcRef' on exit,
-- whether 'withGhciProcess' completed normally or threw.
result <- trySync
$ bracket_
(pure ())
(atomically (writeTVar currentProcRef Nothing))
$ withGhciProcess def ("cabal repl " <> target) projectRoot onProgress onReady \ghci _ ->
atomically (readTVar abortedRef) >>= \case
-- An interrupt may have landed during the
-- @cabal repl@ load. The returned value is
-- discarded by the run loop once it sees
-- 'abortedRef'.
True -> pure (Right [])
False -> case testTimeout of
secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress
secs ->
timeout (fromIntegral secs :: Second) (execGhci ghci ":main" noProgress) >>= \case
Nothing -> pure (Left secs)
Just ls -> pure (Right ls)
case result of
Left ex ->
pure $ TestRunErrored $ TestRunError {target, message = show ex}
Right (Left secs) -> do
Log.warn $ "Test suite " <> target <> " timed out after " <> show secs <> "s"
pure $ TestRunErrored $ TestRunError {target, 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 ->
TestRunErrored $ TestRunError {target, message = msg}
outcome ->
TestRunCompleted
$ TestRunCompletion
{ target
, passed = outcome == GhciPassed
, output
, testCases = parseHspecOutput output
, duration = parseHspecDuration output
}
-- | 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.
runTestRunnerScripted
:: forall es a
. (Concurrent :> es, IOE :> es)
=> [Either SomeException TestRun]
-> Eff (TestRunner : es) a
-> Eff es a
runTestRunnerScripted results act = do
-- Mirror the IO interpreter's abort semantics so tests that drive
-- 'runTestsIfClean' through an interrupt can actually observe the
-- short-circuit via 'isAborted'. A hard-coded 'pure False' would
-- silently mask any regression in that flow.
abortedRef <- newTVarIO False
reinterpret
(evalState results)
( \_ -> \case
RunTestSuite _ -> popResult
InterruptCurrent -> atomically (writeTVar abortedRef True)
ResetAbort -> atomically (writeTVar abortedRef False)
IsAborted -> atomically (readTVar abortedRef)
)
act
where
popResult :: Eff (State [Either SomeException TestRun] : es) TestRun
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
-- | Progress callback used while a test suite is running. Reads the test
-- runner's abort flag before applying the update so that pipe-buffered
-- '[N of M] Compiling' lines emitted by a dying test process — after the
-- user has already touched a file — do not push the counter forward.
abortGatedProgress
:: (BuildStore :> es, Concurrent :> es)
=> TVar Bool -> Text -> GhciLoading -> Eff es ()
abortGatedProgress abortedRef target loading = do
aborted <- atomically (readTVar abortedRef)
unless aborted $ reportTestProgress target loading
-- | Patch live compile progress for a test suite into the current 'Testing'
-- phase of the build state.
--
-- A test suite's @cabal repl@ session typically recompiles a slice of the
-- project before running @:main@. Mirroring the main-build progress bar, we
-- update the matching 'TestRunning' entry as each @[N of M] Compiling …@
-- line arrives so the UI shows @running... (N/M)@ live.
--
-- The update is best-effort: if the phase has moved on (e.g. a source change
-- triggered 'Restarting' or 'Building'), the progress event is dropped rather
-- than reverting the phase.
reportTestProgress
:: (BuildStore :> es) => Text -> GhciLoading -> Eff es ()
reportTestProgress target loading =
modifyPhase \state -> case state.phase of
Testing partialResult ->
let progress = BuildProgress {compiled = loading.index, total = loading.total}
updateRun (TestRunning t _) | t == target = TestRunning t (Just progress)
updateRun r = r
newRuns = map updateRun partialResult.testRuns
in Testing partialResult {testRuns = newRuns}
other -> other
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