reflex-vty-1.3.0.0: test/Reflex/Vty/Test/AppHarness.hs
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- |
-- Description: A deterministic harness around the production widget host
--
-- This module runs real widget applications through
-- 'Reflex.Vty.Widget.mainWidgetWithHandle' against a fully implemented
-- recording 'V.Vty'. It never recreates the widget transformer stack.
--
-- Determinism comes from /fences/. Every injected terminal input is
-- followed by a reserved marker event ('V.KFun' with a negative
-- argument, which terminals cannot produce). Every injected command is
-- followed by a control message. The widget tree observes the
-- marker's arrival and stamps it onto the next drawn frame. A step
-- therefore returns exactly the frame in which its input or command
-- took effect. Markers never reach the application under test. All
-- waits are STM-based and race against host failure, so a dying host
-- can never hang a test. Every step has a deadline. Timeouts report
-- the outstanding fence, recent frames, and the output\/lifecycle
-- log.
module Reflex.Vty.Test.AppHarness
( -- * Widget applications under test
TestWidget
, TestApp
, Frame (..)
, AppDriver (..)
, withApp
, awaitFrameWhereFor
-- * Failures
, HarnessTimeout (..)
, HostFailure (..)
, UnexpectedOperation (..)
-- * Lower-level host access
, RecordingVtyConfig (..)
, defaultRecordingVtyConfig
, runHostAsync
, recordingVty
, RecordingState (..)
, recordingLog
, recordingLogHistory
, recordingInput
, recordingSetBounds
, recordingReleaseGate
) where
import Control.Concurrent.Async
( Async
, async
, cancel
, poll
, waitCatch
, waitCatchSTM
)
import Control.Concurrent.STM
( STM
, TMVar
, TQueue
, TVar
, atomically
, check
, modifyTVar'
, newEmptyTMVarIO
, newTMVarIO
, newTQueueIO
, newTVarIO
, readTMVar
, readTQueue
, readTVar
, readTVarIO
, retry
, tryReadTQueue
, writeTQueue
, writeTVar
)
import Control.Exception (Exception, SomeException, catch, displayException, throwIO, try)
import Control.Monad (forM_, void, when)
import Control.Monad.IO.Class (liftIO)
import qualified Data.ByteString as BS
import Data.IORef
( IORef
, atomicModifyIORef'
, modifyIORef'
, newIORef
, readIORef
, writeIORef
)
import Data.List (intercalate)
import GHC.Conc (unsafeIOToSTM)
import qualified Graphics.Vty as V
import Graphics.Vty.Attributes.Color (ColorMode (..))
import Reflex
import System.Timeout (timeout)
import Control.Monad.NodeId (MonadNodeId)
import Reflex.Vty.Host (MonadVtyApp, VtyApp, VtyAppConfig)
import qualified Reflex.Vty.Host as Host
import Reflex.Vty.Test.Snapshot (Grid, pictureToGrid)
import Reflex.Vty.Widget
-- | Exactly the constraint set 'Reflex.Vty.Widget.mainWidgetWithHandle'
-- accepts for its child widget. This is a constraint alias only: the
-- application under test runs in the production widget stack.
type TestWidget t m =
( MonadVtyApp t m
, MonadNodeId m
, HasImageWriter t m
, HasDisplayRegion t m
, HasFocusReader t m
, HasInput t m
, HasTheme t m
, HasColorProfile t m
, HasCursor t m
, HasScreenMode t m
)
-- | A widget application under test: it receives an event of external
-- commands (fired by 'stepCommand') and returns its current
-- observation together with a shutdown event.
type TestApp command observation =
forall t m. TestWidget t m => Event t command -> m (Dynamic t observation, Event t ())
-- | One drawn frame: what the terminal actually received.
data Frame observation = Frame
{ frameNumber :: !Int
-- ^ Monotonically increasing draw counter.
, frameFence :: !Word
-- ^ The fence stamped into this frame (0 = none yet).
, frameViewport :: !V.DisplayRegion
-- ^ The root viewport the application actually rendered with.
, frameGrid :: Grid
-- ^ The rendered picture, normalized through the independent oracle.
, frameCursor :: V.Cursor
-- ^ The picture's cursor request.
, framePicture :: V.Picture
-- ^ The raw picture, for assertion diagnostics.
, frameObservation :: observation
-- ^ The application's observed value at draw time.
}
-- | The handle for driving one running application.
data AppDriver command observation = AppDriver
{ stepInput :: V.Event -> IO (Frame observation)
-- ^ Deliver a terminal input event and return the frame in which it
-- took effect.
, stepCommand :: command -> IO (Frame observation)
-- ^ Fire an external command and return the frame in which it took
-- effect.
, resizeApp :: V.DisplayRegion -> IO (Frame observation)
-- ^ Change the terminal size, run the production resize debounce,
-- and return the first frame rendered at the requested size.
, awaitFrameWhere :: (Frame observation -> Bool) -> IO (Frame observation)
-- ^ Block (STM) until a /new/ frame satisfies the predicate.
, driverAwaitFor :: Int -> (Frame observation -> Bool) -> IO (Frame observation)
-- ^ 'awaitFrameWhere' with an explicit microsecond deadline.
, stopApp :: IO ()
-- ^ Request shutdown and wait for the host to finish.
, driverFrames :: IO [Frame observation]
-- ^ All frames recorded so far, oldest first.
, driverOutputLog :: IO [String]
-- ^ Ordered input, control, output-byte, mode-change, and shutdown
-- trace.
, driverReleaseRedrawGate :: IO ()
-- ^ Release the redraw gate (a no-op unless the recorder was created
-- with gating enabled).
, driverHostResult :: IO (Maybe (Either SomeException ()))
-- ^ 'Nothing' while the host runs; otherwise its final result.
}
-- | 'awaitFrameWhere' with an explicit microsecond deadline.
awaitFrameWhereFor
:: AppDriver command observation -> Int -> (Frame observation -> Bool) -> IO (Frame observation)
awaitFrameWhereFor = driverAwaitFor
-- | Default per-operation deadline (microseconds).
defaultDeadline :: Int
defaultDeadline = 10 * 1000000
-- | Deadline for sustained-trigger scenarios, including the production
-- resize debounce and scrollbar quiet period (microseconds).
slowDeadline :: Int
slowDeadline = 30 * 1000000
-- | A step deadline expired.
newtype HarnessTimeout = HarnessTimeout String
deriving (Show)
instance Exception HarnessTimeout
-- | The host failed while a step was waiting.
newtype HostFailure = HostFailure String
deriving (Show)
instance Exception HostFailure
----------------------------------------------------------------------------
-- Recording Vty
----------------------------------------------------------------------------
-- | The recording terminal throws this for operations that no
-- supported host action performs.
newtype UnexpectedOperation = UnexpectedOperation String
deriving (Show)
instance Exception UnexpectedOperation
-- | Configuration for the recording terminal.
data RecordingVtyConfig = RecordingVtyConfig
{ recordingColorMode :: V.ColorMode
-- ^ Reported terminal color mode ('FullColor' by default).
, recordingGateRedraw :: Bool
-- ^ When true, every redraw blocks until 'recordingReleaseGate'.
, recordingInitialBounds :: V.DisplayRegion
, recordingOnPicture :: V.Picture -> IO ()
-- ^ Called on every redraw with the drawn picture.
}
-- | A sensible default: full color, no gating, 80x24, no observer.
defaultRecordingVtyConfig :: RecordingVtyConfig
defaultRecordingVtyConfig =
RecordingVtyConfig
{ recordingColorMode = FullColor
, recordingGateRedraw = False
, recordingInitialBounds = (80, 24)
, recordingOnPicture = \_ -> pure ()
}
-- | The mutable state behind the recording terminal.
data RecordingState = RecordingState
{ rsInputQueue :: TQueue V.Event
, rsBoundsRef :: IORef V.DisplayRegion
, rsGate :: TVar Bool
, rsLogRef :: IORef [String]
}
-- | Append to the ordered operation log (oldest first when read back).
recordingLog :: RecordingState -> String -> IO ()
recordingLog st msg = atomicModifyIORef' (rsLogRef st) (\acc -> (msg : acc, ()))
-- | Read the full ordered operation log.
recordingLogHistory :: RecordingState -> IO [String]
recordingLogHistory st = reverse <$> readIORef (rsLogRef st)
-- | Enqueue a terminal input event.
recordingInput :: RecordingState -> V.Event -> IO ()
recordingInput st e = do
recordingLog st ("input: " ++ show e)
atomically $ writeTQueue (rsInputQueue st) e
-- | Change the terminal size reported by the recorder.
recordingSetBounds :: RecordingState -> V.DisplayRegion -> IO ()
recordingSetBounds st b = do
recordingLog st ("resize: " ++ show b)
writeIORef (rsBoundsRef st) b
-- | Release the redraw gate (a no-op when ungated).
recordingReleaseGate :: RecordingState -> IO ()
recordingReleaseGate st = atomically (writeTVar (rsGate st) True)
-- | Build a fully implemented recording 'V.Vty' plus its state. Every
-- operation is real; operations unreachable through initialization,
-- rendering, mode changes, and teardown throw 'UnexpectedOperation'
-- rather than diverging.
recordingVty :: RecordingVtyConfig -> IO (V.Vty, RecordingState)
recordingVty cfg = do
inputQueue <- newTQueueIO
boundsRef <- newIORef (recordingInitialBounds cfg)
gate <-
if recordingGateRedraw cfg
then newTVarIO False
else newTVarIO True
logRef <- newIORef ([] :: [String])
let st =
RecordingState
{ rsInputQueue = inputQueue
, rsBoundsRef = boundsRef
, rsGate = gate
, rsLogRef = logRef
}
log msg = recordingLog st msg
updateAction pic = do
-- Blocking on a closed latch gates redraws in backpressure
-- tests; an ungated latch returns immediately. The latch is
-- level-triggered: every redraw waits until it is open.
atomically (readTVar gate >>= check)
log "draw"
recordingOnPicture cfg pic
input =
V.Input
{ V.eventChannel = error "recordingVty: eventChannel unused (nextEvent is used)"
, V.shutdownInput = log "input:shutdown"
, V.restoreInputState = log "input:restore"
, V.inputLogMsg = \_ -> pure ()
}
out <- recordingOutput cfg log boundsRef
let vty =
V.Vty
{ V.update = updateAction
, V.nextEvent = atomically (readTQueue inputQueue)
, V.nextEventNonblocking = atomically (tryReadTQueue inputQueue)
, V.inputIface = input
, V.outputIface = out
, V.refresh = log "refresh"
, V.shutdown = log "vty:shutdown"
, V.isShutdown = pure False
}
pure (vty, st)
recordingOutput :: RecordingVtyConfig -> (String -> IO ()) -> IORef V.DisplayRegion -> IO V.Output
recordingOutput cfg log boundsRef =
pure output
where
output =
V.Output
{ V.terminalID = "reflex-vty-test-recorder"
, V.releaseTerminal = log "output:releaseTerminal"
, V.reserveDisplay = log "output:reserveDisplay"
, V.releaseDisplay = log "output:releaseDisplay"
, V.setDisplayBounds = \r -> log ("output:setDisplayBounds " ++ show r)
, V.displayBounds = readIORef boundsRef
, V.outputByteBuffer = \bs -> log ("output:bytes[" ++ show (BS.length bs) ++ "] " ++ show bs)
, V.supportsCursorVisibility = True
, V.supportsMode = const True
, V.setMode = \m onOff -> log ("output:setMode " ++ show (m, onOff))
, V.getModeStatus = \m -> do
log ("output:getModeStatus " ++ show m)
pure False
, V.assumedStateRef = error "recordingVty: assumedStateRef unreachable"
, V.mkDisplayContext = \_ _ ->
throwIO (UnexpectedOperation "mkDisplayContext: the recorder draws no display context")
, V.ringTerminalBell = log "output:bell"
, V.supportsBell = pure True
, V.supportsItalics = pure True
, V.supportsStrikethrough = pure True
, V.outputColorMode = recordingColorMode cfg
, V.setOutputWindowTitle = \t -> log ("output:title " ++ show t)
}
-- | Run the production host asynchronously. For startup-shutdown and
-- failure-injection scenarios that must not wait for a first frame.
runHostAsync :: VtyAppConfig -> V.Vty -> (forall t m. VtyApp t m) -> IO (Async ())
runHostAsync cfg vty app = async (Host.runVtyAppWithHandle cfg vty app)
----------------------------------------------------------------------------
-- Fence markers and control messages
----------------------------------------------------------------------------
-- | The reserved marker event encoding fence @n@. Terminals cannot
-- deliver negative function-key arguments, so these never collide with
-- real input.
markerEvent :: Word -> V.Event
markerEvent n = V.EvKey (V.KFun (negate (fromIntegral n))) []
markerFenceOf :: V.Event -> Maybe Word
markerFenceOf e = case e of
V.EvKey (V.KFun n) [] | n < 0 -> Just (fromIntegral (negate n))
_ -> Nothing
dropMarker :: V.Event -> Maybe V.Event
dropMarker e = case markerFenceOf e of
Just _ -> Nothing
Nothing -> Just e
-- | Control-stream messages. The application only ever sees
-- 'CtrlCommand' payloads.
data Ctrl c = CtrlCommand c | CtrlFence Word | CtrlStop
isCtrlStop :: Ctrl c -> Bool
isCtrlStop CtrlStop = True
isCtrlStop _ = False
ctrlCommand :: Ctrl c -> Maybe c
ctrlCommand (CtrlCommand c) = Just c
ctrlCommand _ = Nothing
ctrlFence :: Ctrl c -> Maybe Word
ctrlFence (CtrlFence n) = Just n
ctrlFence _ = Nothing
----------------------------------------------------------------------------
-- Frame waiting
----------------------------------------------------------------------------
-- | Block (STM) until a frame newer than the baseline satisfies the
-- predicate. The wait races against host failure. A deadline expiry
-- throws 'HarnessTimeout' with diagnostics.
awaitNewFrame
:: forall observation
. TVar [Frame observation]
-> IORef (Maybe (Async ()))
-> IO [String]
-> Int
-> Int
-> (Frame observation -> Bool)
-> IO (Frame observation)
awaitNewFrame framesVar hostRef logs deadline baseline p = do
r <- timeout deadline (atomically stm)
case r of
Nothing -> timeoutFailure
Just (Left hostErr) -> do
details <- diagnostics
throwIO
( HostFailure $
"host failed while waiting for a frame: "
++ hostErr
++ "\n"
++ details
)
Just (Right f) -> pure f
where
stm :: STM (Either String (Frame observation))
stm = do
fs <- readTVar framesVar
case [f | f <- drop baseline fs, p f] of
(f : _) -> pure (Right f)
[] -> do
mh <- unsafeIOToSTM (readIORef hostRef)
case mh of
Just h -> do
r <- waitCatchSTM h
case r of
Left e -> pure (Left (displayException e))
Right _ -> retry
Nothing -> retry
diagnostics = do
fs <- readTVarIO framesVar
logLines <- logs
pure $
"frames="
++ show (length fs)
++ " baseline="
++ show baseline
++ "\nlast frames:\n"
++ intercalate "\n" (map describeFrame (drop (max 0 (length fs - 5)) fs))
++ "\nlog (tail):\n"
++ intercalate "\n" (drop (max 0 (length logLines - 30)) logLines)
timeoutFailure = do
details <- diagnostics
throwIO (HarnessTimeout ("harness timeout after " ++ show deadline ++ "us\n" ++ details))
describeFrame f =
"frame#"
++ show (frameNumber f)
++ " fence="
++ show (frameFence f)
++ " viewport="
++ show (frameViewport f)
++ " cursor="
++ show (frameCursor f)
----------------------------------------------------------------------------
-- withApp
----------------------------------------------------------------------------
-- | Run a 'TestApp' against a recording terminal of the given initial
-- size. Hand the driver to the continuation. The harness awaits the
-- initial frame before the continuation runs. When the continuation
-- returns (or throws), the harness shuts down and joins the host.
withApp
:: forall command observation a
. Show observation
=> VtyAppConfig
-> V.DisplayRegion
-> TestApp command observation
-> (AppDriver command observation -> IO a)
-> IO a
withApp cfg size app k = do
framesVar <- newTVarIO ([] :: [Frame observation]) -- oldest first
obsBug <- newIORef (0 :: Int)
frameCounter <- newIORef (0 :: Int)
fenceCounter <- newIORef (0 :: Word)
lastFenceRef <- newIORef (0 :: Word)
obsRef <- newIORef (Nothing :: Maybe observation)
regionRef <- newIORef size
ctrlRef <- newIORef (Nothing :: Maybe (Ctrl command -> IO () -> IO ()))
hostRef <- newIORef (Nothing :: Maybe (Async ()))
(vty, st) <-
recordingVty
defaultRecordingVtyConfig
{ recordingInitialBounds = size
, recordingOnPicture = \pic -> do
n <- atomicModifyIORef' frameCounter (\i -> (i + 1, i + 1))
fence <- readIORef lastFenceRef
vp <- readIORef regionRef
mobs <- readIORef obsRef
case mobs of
Nothing ->
modifyIORef' obsBug (+ 1)
Just o ->
atomically $
modifyTVar' framesVar (++ [Frame n fence vp (pictureToGrid vp pic) (V.picCursor pic) pic o])
}
let stampFence n = writeIORef lastFenceRef n
nextFence = atomicModifyIORef' fenceCounter (\i -> (i + 1, i + 1))
fireCtrl msg k' = do
mfire <- readIORef ctrlRef
case mfire of
Nothing -> throwIO (UnexpectedOperation "control stream not yet initialized")
Just fire -> fire msg k'
awaitNew deadline baseline p =
awaitNewFrame framesVar hostRef (recordingLogHistory st) deadline baseline p
wrapper :: forall t m. TestWidget t m => m (Event t ())
wrapper = do
inp <- input
pb <- getPostBuild
-- Track the root viewport the application actually uses
-- (after the production resize debounce).
vp <- askViewport
performEvent_ $
ffor (leftmost [tag (current vp) pb, updated vp]) $ \r ->
liftIO $ writeIORef regionRef (_region_width r, _region_height r)
-- External command stream.
(ctrl, fireCtrlRef) <- newTriggerEventWithOnComplete
liftIO $ writeIORef ctrlRef (Just fireCtrlRef)
-- The application under test: markers filtered out of its
-- input; commands from the control stream.
(obsDyn, appShutdown) <-
localInput (fmapMaybe dropMarker) $
app (fmapMaybe ctrlCommand ctrl)
-- Observation stamping: initial value at post-build, then
-- every update, so each draw sees the settled value.
performEvent_ $
ffor (leftmost [tag (current obsDyn) pb, updated obsDyn]) $
liftIO . writeIORef obsRef . Just
-- Fences: control-stream fences and input markers.
performEvent_ $ ffor (fmapMaybe ctrlFence ctrl) (liftIO . stampFence)
performEvent_ $ ffor (fmapMaybe markerFenceOf inp) (liftIO . stampFence)
let stop = () <$ ffilter isCtrlStop ctrl
return $ leftmost [appShutdown, stop]
hostA <- async (mainWidgetWithHandle cfg vty wrapper)
writeIORef hostRef (Just hostA)
let awaitFrameFenced n = do
fs0 <- readTVarIO framesVar
awaitNew defaultDeadline (length fs0) ((== n) . frameFence)
stepInputImpl ev = do
n <- nextFence
recordingInput st ev
recordingInput st (markerEvent n)
awaitFrameFenced n
stepCommandImpl c = do
n <- nextFence
fireCtrl (CtrlCommand c) (pure ())
fireCtrl (CtrlFence n) (pure ())
awaitFrameFenced n
resizeImpl dims = do
recordingSetBounds st dims
n <- nextFence
recordingInput st (uncurry V.EvResize dims)
recordingInput st (markerEvent n)
_ <- awaitFrameFenced n
fs0 <- readTVarIO framesVar
awaitNew slowDeadline (length fs0) ((== dims) . frameViewport)
stopImpl = do
mfire <- readIORef ctrlRef
forM_ mfire $ \fire ->
fire CtrlStop (pure ()) `catch` \(_ :: SomeException) -> pure ()
joinHost
joinHost = do
mh <- readIORef hostRef
forM_ mh $ \h -> do
pr <- poll h
case pr of
Just _ -> pure ()
Nothing -> do
r <- timeout defaultDeadline (waitCatch h)
case r of
Nothing -> cancel h >> void (waitCatch h)
Just _ -> pure ()
driver =
AppDriver
{ stepInput = stepInputImpl
, stepCommand = stepCommandImpl
, resizeApp = resizeImpl
, awaitFrameWhere = \p -> do
fs0 <- readTVarIO framesVar
awaitNew defaultDeadline (length fs0) p
, driverAwaitFor = \deadline p -> do
fs0 <- readTVarIO framesVar
awaitNew deadline (length fs0) p
, stopApp = stopImpl
, driverFrames = readTVarIO framesVar
, driverOutputLog = recordingLogHistory st
, driverReleaseRedrawGate = recordingReleaseGate st
, driverHostResult = do
mh <- readIORef hostRef
case mh of
Nothing -> pure Nothing
Just h -> poll h
}
-- The initial frame must exist before interaction begins (or the
-- host must fail loudly rather than hang).
_ <- awaitNew defaultDeadline 0 (const True)
result <- try (k driver)
stopImpl
case result of
Left e -> throwIO (e :: SomeException)
Right a -> pure a