packages feed

tricorder-0.1.0.0: test/Unit/Tricorder/TestRunnerSpec.hs

module Unit.Tricorder.TestRunnerSpec (spec_TestRunner) where

import Atelier.Effects.Clock (runClockConst)
import Atelier.Effects.Delay (runDelay)
import Atelier.Effects.Input (runInputConst)
import Atelier.Effects.Log (runLogNoOp)
import Control.Concurrent.STM (newTVarIO, writeTVar)
import Control.Exception (ErrorCall (..))
import Data.Time (UTCTime (..), fromGregorian)
import Effectful (IOE, runEff)
import Effectful.Concurrent (Concurrent, runConcurrent)
import Effectful.Concurrent.STM (atomically)
import Effectful.Exception (try)
import Effectful.Reader.Static (runReader)
import Effectful.State.Static.Shared (evalState)
import Test.Hspec

import Tricorder.BuildState
    ( BuildId (..)
    , BuildPhase (..)
    , BuildProgress (..)
    , BuildResult (..)
    , BuildState (..)
    , DaemonInfo (..)
    , TestRun (..)
    , TestRunCompletion (..)
    )
import Tricorder.Effects.BuildStore (getState)
import Tricorder.Effects.GhciSession.GhciParser (GhciLoading (..))
import Tricorder.Effects.TestRunner
    ( GhciOutcome (..)
    , TestRunner
    , abortGatedProgress
    , detectOutcome
    , interruptCurrent
    , isAborted
    , resetAbort
    , runTestRunnerScripted
    , runTestSuite
    )
import Tricorder.Runtime (ProjectRoot (..))

import Tricorder.Effects.BuildStore qualified as BuildStore


spec_TestRunner :: Spec
spec_TestRunner = do
    describe "detectOutcome" testDetectOutcome
    describe "runTestRunnerScripted" testScripted
    describe "abortGatedProgress" testAbortGatedProgress


--------------------------------------------------------------------------------
-- detectOutcome tests
--------------------------------------------------------------------------------

testDetectOutcome :: Spec
testDetectOutcome = do
    describe "no exception line" do
        it "treats empty output as pass" do
            detectOutcome "" `shouldBe` GhciPassed

        it "treats output with no exception as pass" do
            detectOutcome "2 examples, 0 failures\n" `shouldBe` GhciPassed

        it "does not match 'ExitSuccess' without the exception prefix" do
            detectOutcome "ExitSuccess\n" `shouldBe` GhciPassed

    describe "ExitSuccess" do
        it "detects ExitSuccess as pass" do
            detectOutcome "*** Exception: ExitSuccess\n" `shouldBe` GhciPassed

        it "detects ExitSuccess anywhere in output" do
            detectOutcome "All tests passed\n*** Exception: ExitSuccess\n"
                `shouldBe` GhciPassed

    describe "ExitFailure" do
        it "detects ExitFailure 1 as fail" do
            detectOutcome "1 failure\n*** Exception: ExitFailure 1\n"
                `shouldBe` GhciFailed

        it "detects ExitFailure with any exit code as fail" do
            detectOutcome "*** Exception: ExitFailure 42\n" `shouldBe` GhciFailed

        it "detects ExitFailure anywhere in output" do
            detectOutcome "Some output\n*** Exception: ExitFailure 1\nMore output\n"
                `shouldBe` GhciFailed

    describe "other exception" do
        it "classifies unknown exception as error with message" do
            detectOutcome "*** Exception: SomeException \"oops\"\n"
                `shouldBe` GhciCrashed "SomeException \"oops\""

        it "trims trailing whitespace from the error message" do
            detectOutcome "*** Exception: Crashed  \n"
                `shouldBe` GhciCrashed "Crashed"

    describe "compile failure (no exception line, but GHC errors present)" do
        it "flags ':main not in scope' as crashed" do
            detectOutcome "<interactive>:1:1: error: [GHC-76037] Not in scope: 'main'\n"
                `shouldBe` GhciCrashed
                    "<interactive>:1:1: error: [GHC-76037] Not in scope: 'main'"

        it "flags a source-file compile error as crashed" do
            detectOutcome "src/Foo.hs:42:5: error: Variable not in scope: foo\n"
                `shouldBe` GhciCrashed "src/Foo.hs:42:5: error: Variable not in scope: foo"

        it "reports the first error line when multiple are present" do
            detectOutcome
                "src/Foo.hs:42:5: error: Variable not in scope: foo\nsrc/Bar.hs:10:1: error: Parse error\n"
                `shouldBe` GhciCrashed "src/Foo.hs:42:5: error: Variable not in scope: foo"

        it "prefers exit exception over compile-error heuristic when both appear" do
            -- A real failing run could plausibly mention 'error:' in its
            -- captured output (e.g. logged messages); the ExitFailure line
            -- still wins.
            detectOutcome "log: error: something happened\n*** Exception: ExitFailure 1\n"
                `shouldBe` GhciFailed


--------------------------------------------------------------------------------
-- Scripted interpreter tests
--------------------------------------------------------------------------------

testScripted :: Spec
testScripted = do
    it "returns scripted TestRun" do
        result <- runScripted [Right passingRun] $ runTestSuite "test:foo"
        result `shouldBe` passingRun

    it "ignores the target name argument" do
        result <- runScripted [Right failingRun] $ runTestSuite "test:anything"
        result `shouldBe` failingRun

    it "throws when scripted result is Left" do
        result <-
            runScripted [Left (toException boom)]
                $ try @ErrorCall
                $ runTestSuite "test:foo"
        result `shouldBe` Left boom

    describe "sequencing" do
        it "consumes results in order across multiple calls" do
            (a, b) <- runScripted [Right passingRun, Right failingRun] do
                a <- runTestSuite "test:foo"
                b <- runTestSuite "test:bar"
                pure (a, b)
            a `shouldBe` passingRun
            b `shouldBe` failingRun

        it "recover scenario: error then success" do
            result <- runScripted [Left (toException boom), Right passingRun] do
                r1 <- try @ErrorCall $ runTestSuite "test:foo"
                r2 <- runTestSuite "test:bar"
                pure (r1, r2)
            fst result `shouldBe` Left boom
            snd result `shouldBe` passingRun

    describe "abort flag" do
        -- The scripted interpreter must mirror 'runTestRunnerIO': any test
        -- that exercises the 'runTestsIfClean' abort short-circuit through
        -- the scripted runner relies on 'isAborted' reflecting prior calls
        -- to 'interruptCurrent'/'resetAbort'. Hard-coding 'pure False' here
        -- silently masks regressions in that flow.
        it "defaults to not aborted" do
            aborted <- runScripted [] isAborted
            aborted `shouldBe` False

        it "interruptCurrent sets the flag" do
            aborted <- runScripted [] do
                interruptCurrent
                isAborted
            aborted `shouldBe` True

        it "resetAbort clears the flag" do
            aborted <- runScripted [] do
                interruptCurrent
                resetAbort
                isAborted
            aborted `shouldBe` False


--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

boom :: ErrorCall
boom = ErrorCall "simulated process crash"


passingRun :: TestRun
passingRun =
    TestRunCompleted
        $ TestRunCompletion
            { target = "test:foo"
            , passed = True
            , output = "2 examples, 0 failures\n"
            , testCases = []
            , duration = Nothing
            }


failingRun :: TestRun
failingRun =
    TestRunCompleted
        $ TestRunCompletion
            { target = "test:bar"
            , passed = False
            , output = "1 example, 1 failure\n"
            , testCases = []
            , duration = Nothing
            }


runScripted :: [Either SomeException TestRun] -> Eff '[TestRunner, Concurrent, IOE] a -> IO a
runScripted results = runEff . runConcurrent . runTestRunnerScripted results


--------------------------------------------------------------------------------
-- abortGatedProgress tests
--
-- Regression for the "module counter glitches after interrupt" bug: after
-- the test runner sets 'abortedRef' = True and terminates a test process,
-- the dying process can still push pipe-buffered '[N of M] Compiling …'
-- lines through the drain, and each one used to call 'reportTestProgress'
-- and tick the UI counter forward. 'abortGatedProgress' must drop those
-- updates instead.
--------------------------------------------------------------------------------

testAbortGatedProgress :: Spec
testAbortGatedProgress = do
    it "applies the progress update when abortedRef is False" do
        finalRuns <- runProgress False progress42 startingRuns
        finalRuns `shouldBe` [TestRunning "test:foo" (Just expected42)]

    it "drops the progress update when abortedRef is True" do
        finalRuns <- runProgress True progress42 startingRuns
        finalRuns `shouldBe` startingRuns

    it "drops every update applied while abortedRef stays True" do
        abortedRef <- newTVarIO True
        let loadings = [mkLoading i 10 | i <- [1 .. 5]]
        finalRuns <- runStore do
            BuildStore.setPhase (BuildId 1) (Testing (partialResultWith startingRuns))
            for_ loadings (abortGatedProgress abortedRef "test:foo")
            phaseTestRuns <$> getState
        finalRuns `shouldBe` startingRuns

    it "flips behaviour mid-run if abortedRef is set between updates" do
        abortedRef <- newTVarIO False
        finalRuns <- runStore do
            BuildStore.setPhase (BuildId 1) (Testing (partialResultWith startingRuns))
            -- This one applies.
            abortGatedProgress abortedRef "test:foo" (mkLoading 3 10)
            -- Simulate the interrupt firing.
            atomically (writeTVar abortedRef True)
            -- These should now be dropped.
            abortGatedProgress abortedRef "test:foo" (mkLoading 8 10)
            abortGatedProgress abortedRef "test:foo" (mkLoading 9 10)
            phaseTestRuns <$> getState
        finalRuns
            `shouldBe` [TestRunning "test:foo" (Just BuildProgress {compiled = 3, total = 10})]
  where
    startingRuns = [TestRunning "test:foo" Nothing]
    progress42 = mkLoading 4 10
    expected42 = BuildProgress {compiled = 4, total = 10}

    runProgress aborted loading runs = do
        abortedRef <- newTVarIO aborted
        runStore do
            BuildStore.setPhase (BuildId 1) (Testing (partialResultWith runs))
            abortGatedProgress abortedRef "test:foo" loading
            phaseTestRuns <$> getState

    runStore =
        runEff
            . runConcurrent
            . runDelay
            . runClockConst epoch
            . runReader (ProjectRoot "/")
            . evalState (BuildId 1)
            . runLogNoOp
            . runInputConst emptyDaemonInfo
            . BuildStore.runBuildStore

    mkLoading i tot =
        GhciLoading
            { index = i
            , total = tot
            , moduleName = "Mod"
            , sourceFile = "Mod.hs"
            }

    partialResultWith runs =
        BuildResult
            { completedAt = epoch
            , duration = 0
            , moduleCount = 0
            , diagnostics = []
            , testRuns = runs
            }

    phaseTestRuns :: BuildState -> [TestRun]
    phaseTestRuns s = case s.phase of
        Testing r -> r.testRuns
        _ -> []

    epoch :: UTCTime
    epoch = UTCTime (fromGregorian 2024 1 1) 0

    emptyDaemonInfo :: DaemonInfo
    emptyDaemonInfo =
        DaemonInfo
            { targets = []
            , watchDirs = []
            , sockPath = ""
            , logFile = ""
            , metricsPort = Nothing
            }