packages feed

skeletest-0.4.0: test/Skeletest/TestUtils/Integration.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}

module Skeletest.TestUtils.Integration (
  integration,

  -- * Test runner
  TestRunner,
  FileContents,

  -- * runTests
  TestArgs (..),
  expectCode,
  expectSuccess,
  expectFailure,

  -- * Re-exports
  ExitCode (..),
  def,
) where

import Control.Monad (guard)
import Data.Char (isDigit)
import Data.Default (Default (..))
import Data.IORef (IORef, modifyIORef, newIORef, readIORef)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import GHC.Records (HasField (..))
import Skeletest
import Skeletest.TestUtils.ANSI (Terminal (..), TerminalType (..), defaultTerminal)
import System.Directory (createDirectoryIfMissing)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, (</>))
import System.IO.Error (isDoesNotExistError)
import System.Process qualified as Process
import UnliftIO.Exception (handleJust)

data MarkerIntegration = MarkerIntegration
  deriving (Show)

instance IsMarker MarkerIntegration where
  getMarkerName _ = "integration"

integration :: Spec -> Spec
integration = markManual . withMarker MarkerIntegration

{----- runTests -----}

data TestRunner = TestRunner
  { dir :: FilePath
  , settingsRef :: IORef TestRunnerSettings
  }

data TestRunnerSettings = TestRunnerSettings
  { mainFileContents :: FileContents
  }

-- | File contents as a list of lines.
type FileContents = [String]

instance Fixture TestRunner where
  fixtureAction = do
    FixtureTmpDir dir <- getFixture
    settingsRef <- newIORef defaultSettings
    pure $ noCleanup TestRunner{..}
   where
    defaultSettings =
      TestRunnerSettings
        { mainFileContents = ["import Skeletest.Main"]
        }

instance HasField "setMainFile" TestRunner (FileContents -> IO ()) where
  getField runner contents =
    modifyIORef runner.settingsRef $ \settings ->
      settings{mainFileContents = contents}

instance HasField "addTestFile" TestRunner (FilePath -> FileContents -> IO ()) where
  getField runner fp contents = do
    let path = runner.dir </> fp
    createDirectoryIfMissing True (takeDirectory path)
    writeFile path (unlines contents)

instance HasField "lookupTestFile" TestRunner (FilePath -> IO (Maybe Text)) where
  getField runner fp =
    handleDNE (\_ -> pure Nothing) . fmap Just $
      runner.readTestFile fp
   where
    handleDNE = handleJust (\e -> guard (isDoesNotExistError e) *> Just e)

instance HasField "readTestFile" TestRunner (FilePath -> IO Text) where
  getField runner fp = Text.readFile $ runner.dir </> fp

data TestArgs = TestArgs
  { cwd :: Maybe FilePath
  , cliArgs :: [String]
  , ghcArgs :: [String]
  , mainFile :: String
  , simulateANSI :: Bool
  }

instance Default TestArgs where
  def =
    TestArgs
      { cwd = Nothing
      , cliArgs = ["--format=full"]
      , ghcArgs = []
      , mainFile = "Main.hs"
      , simulateANSI = True
      }

instance HasField "runTests" TestRunner (IO (ExitCode, String, String)) where
  getField runner = runner.runTestsWith def

instance HasField "runTestsWith" TestRunner (TestArgs -> IO (ExitCode, String, String)) where
  getField runner args = do
    settings <- readIORef runner.settingsRef
    runner.addTestFile args.mainFile settings.mainFileContents

    let ghcArgs =
          concat
            [ ["-hide-all-packages"]
            , ["-F", "-pgmF=skeletest-preprocessor"]
            , ["-package skeletest"]
            , ["-o", "test-runner"]
            , args.ghcArgs
            , [args.mainFile]
            ]
    runProcWith id "ghc" ghcArgs >>= \case
      (ExitSuccess, _, _) ->
        runProcWith
          (maybe id setCWD args.cwd)
          (runner.dir </> "test-runner")
          cliArgs
      result -> pure result
   where
    cliArgs =
      concat
        [ args.cliArgs
        , [if args.simulateANSI then "--ansi=always" else "--ansi=never"]
        ]

    setCWD dir p = p{Process.cwd = Just dir}
    runProcWith f cmd args_ = do
      let proc = f . setCWD runner.dir $ Process.proc cmd args_
      (code, stdout, stderr) <- Process.readCreateProcessWithExitCode proc ""
      pure (code, sanitize stdout, sanitize stderr)

    term =
      defaultTerminal
        { type_ = if args.simulateANSI then AnsiTerminal else LogTerminal
        , width = 80 -- Default for non-ANSI terminals in Skeletest.Internal.Utils.Term
        }

    sanitize =
      Text.unpack
        . Text.strip
        . term.resolve
        . scrubDurations
        . Text.pack
    scrubDurations s =
      case Text.break isDigit s of
        (pre, post)
          -- Check if it's of the form `#.##s`
          | (duration, post') <- Text.splitAt 5 post
          , [c0, '.', c1, c2, 's'] <- Text.unpack duration
          , all isDigit [c0, c1, c2] ->
              pre <> "0.00s" <> scrubDurations post'
          -- Consume the digit, continue on the rest of the string
          | Just (c, post') <- Text.uncons post ->
              pre <> Text.cons c (scrubDurations post')
          -- 'post' is empty; we've checked the whole string
          | otherwise ->
              pre

expectCode :: (HasCallStack) => Int -> IO (ExitCode, String, String) -> IO (String, String)
expectCode expected m = do
  (code, stdout, stderr) <- m
  context (unlines ["===== stdout =====", stdout, "===== stderr =====", stderr]) $
    code `shouldBe` expectedCode
  pure (stdout, stderr)
 where
  expectedCode = if expected == 0 then ExitSuccess else ExitFailure expected

expectSuccess :: (HasCallStack) => IO (ExitCode, String, String) -> IO (String, String)
expectSuccess = expectCode 0

expectFailure :: (HasCallStack) => IO (ExitCode, String, String) -> IO (String, String)
expectFailure = expectCode 1