skeletest-0.4.0: src/Skeletest/Internal/Capture.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}
module Skeletest.Internal.Capture (
captureOutputPlugin,
FixtureCapturedOutput (..),
-- * CLI flag
CaptureOutputFlag (..),
) where
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import GHC.IO.Handle qualified as IO
import Skeletest.Internal.CLI (
FlagSpec (..),
FormatFlag (..),
IsFlag (..),
getFlag,
getFormatFlag,
)
import Skeletest.Internal.CLI qualified as CLI
import Skeletest.Internal.Fixtures (
Fixture (..),
FixtureSkeletestTmpDir (..),
getFixture,
noCleanup,
withCleanup,
)
import Skeletest.Internal.Hooks qualified as Hooks
import Skeletest.Internal.Spec.Output (BoxSpecContent (..))
import Skeletest.Internal.TestRunner (
TestResult (..),
TestResultMessage (..),
)
import Skeletest.Plugin (Hooks (..), Plugin (..), defaultHooks, defaultPlugin)
import System.Directory (removePathForcibly)
import System.IO qualified as IO
import UnliftIO.Exception (finally)
captureOutputPlugin :: Plugin
captureOutputPlugin =
defaultPlugin
{ cliFlags = [CLI.flag @CaptureOutputFlag]
, hooks = captureOutputHooks
}
captureOutputHooks :: Hooks
captureOutputHooks =
defaultHooks
{ runTest = Hooks.mkHook $ \_ run inp -> do
(output, result) <- withCaptureOutput (run inp)
addCapturedOutput output result
}
newtype CaptureOutputFlag = CaptureOutputFlag Bool
instance IsFlag CaptureOutputFlag where
flagName = "capture-output"
flagHelp = "Whether to capture stdout/stderr: on (default), off"
flagSpec =
OptionalFlag
{ default_ = CaptureOutputFlag True
, parse = \case
"off" -> Right $ CaptureOutputFlag False
"on" -> Right $ CaptureOutputFlag True
s -> Left $ "invalid value: " <> s
}
type CapturedOutput = Maybe (Text, Text)
withCaptureOutput :: IO a -> IO (CapturedOutput, a)
withCaptureOutput action = do
CaptureOutputFlag output <- getFlag
if output
then do
handles <- getFixture @FixtureCapturedOutputHandles
(stdout, (stderr, a)) <- capture handles.stdout . capture handles.stderr $ action
pure (Just (stdout, stderr), a)
else (Nothing,) <$> action
where
capture handle m =
withRestore handle.real $ do
IO.hFlush handle.real
IO.hDuplicateTo handle.log handle.real
a <- m
out <- getOutput handle
pure (out, a)
withRestore h m = do
buf <- IO.hGetBuffering h
orig <- IO.hDuplicate h
m `finally` do
IO.hDuplicateTo orig h
IO.hSetBuffering h buf
IO.hClose orig
addCapturedOutput :: CapturedOutput -> TestResult -> IO TestResult
addCapturedOutput mCapturedOutput result = do
format <- getFormatFlag
let output = maybe [] renderOutput mCapturedOutput
pure $
if shouldShowOutput format output
then result{message = addOutput output result.message}
else result
where
renderOutput (stdout, stderr) =
concat
[ renderSection "Captured stdout" stdout
, renderSection "Captured stderr" stderr
]
renderSection name s =
if Text.null s
then []
else [BoxHeader name, BoxText $ Text.stripEnd s]
shouldShowOutput format output
| null output = False
| format == FormatFlag_Verbose = True
| result.status.success = False
| otherwise = True
addOutput output resultMessage =
TestResultMessageBox $ toBoxContents resultMessage <> output
toBoxContents = \case
TestResultMessageNone -> []
TestResultMessageInline msg -> [BoxText msg]
TestResultMessageBox box -> box
data FixtureCapturedOutputHandles = FixtureCapturedOutputHandles
{ stdout :: LogHandle
, stderr :: LogHandle
}
data LogHandle = LogHandle
{ log :: IO.Handle
, real :: IO.Handle
}
initHandle ::
IO.Handle ->
FilePath ->
FilePath ->
IO (LogHandle, IO ())
initHandle real tmpdir file = do
(fp, h) <- IO.openTempFile tmpdir file
IO.hSetBuffering h IO.LineBuffering
let handle = LogHandle{log = h, real = real}
cleanup = IO.hClose h >> removePathForcibly fp
pure (handle, cleanup)
instance Fixture FixtureCapturedOutputHandles where
fixtureAction = do
FixtureSkeletestTmpDir tmpdir <- getFixture
(stdout, cleanupStdout) <- initHandle IO.stdout tmpdir "stdout"
(stderr, cleanupStderr) <- initHandle IO.stderr tmpdir "stderr"
pure . withCleanup FixtureCapturedOutputHandles{..} $ do
cleanupStdout
cleanupStderr
getOutput :: LogHandle -> IO Text
getOutput = readOutputFrom 0
readOutput :: LogHandle -> IO Text
readOutput handle = do
pos <- IO.hTell handle.log
readOutputFrom pos handle
readOutputFrom :: Integer -> LogHandle -> IO Text
readOutputFrom n handle = do
-- Flush buffers
IO.hFlush handle.real
IO.hFlush handle.log
-- Force handle to end of file, to refresh from real handle
IO.hSeek handle.log IO.SeekFromEnd 0
IO.hSeek handle.log IO.AbsoluteSeek n
go ""
where
go acc = do
out <- Text.hGetChunk handle.log
if Text.null out
then pure acc
else go $! acc <> out
-- | Fixture for inspecting the captured output.
--
-- Intended to be used with @OverloadedRecordDot@:
--
-- @
-- output <- getFixture @FixtureCapturedOutput
--
-- -- Read all of stdout/stderr so far
-- stdout <- output.getStdout
-- stderr <- output.getStderr
--
-- -- Read everything in stdout/stderr since the last read
-- stdout_chunk <- output.readStdout
-- stderr_chunk <- output.readStderr
-- @
data FixtureCapturedOutput = FixtureCapturedOutput
{ getStdout :: IO Text
, getStderr :: IO Text
, readStdout :: IO Text
, readStderr :: IO Text
}
instance Fixture FixtureCapturedOutput where
fixtureAction = do
handles <- getFixture @FixtureCapturedOutputHandles
pure . noCleanup $
FixtureCapturedOutput
{ getStdout = getOutput handles.stdout
, getStderr = getOutput handles.stderr
, readStdout = readOutput handles.stdout
, readStderr = readOutput handles.stderr
}