packages feed

chiasma-test-0.10.0.0: lib/Chiasma/Test/Tmux.hs

module Chiasma.Test.Tmux where

import qualified Chronos
import Chronos (datetimeToTime)
import Exon (exon)
import Hedgehog (TestT)
import Hedgehog.Internal.Property (Failure)
import Path (Abs, Dir, File, Path, relfile, (</>))
import Path.IO (createTempDir, doesFileExist, getTempDir, removeDirRecur)
import Polysemy.Chronos (ChronosTime, interpretTimeChronos)
import qualified Polysemy.Conc as Race
import Polysemy.Conc (interpretRace)
import qualified Polysemy.Log as Log
import Polysemy.Log (Severity (Trace), interpretLogStdoutLevelConc)
import Polysemy.Process (interpretSystemProcessNativeOpaqueSingle, resolveExecutable)
import qualified Polysemy.Process.Effect.Pty as Pty
import Polysemy.Process.Effect.Pty (Pty, withPty)
import qualified Polysemy.Process.Effect.SystemProcess as SystemProcess
import Polysemy.Process.Effect.SystemProcess (SystemProcess)
import Polysemy.Process.Interpreter.Pty (interpretPty)
import qualified Polysemy.Test as Test
import Polysemy.Test (Hedgehog, Test, TestError (TestError), runTestAuto)
import qualified Polysemy.Time as Time
import Polysemy.Time (MilliSeconds (MilliSeconds), Seconds (Seconds), mkDatetime)
import System.Process.Typed (ProcessConfig, StreamSpec, proc, setStderr, setStdin, setStdout, useHandleClose)

import Chiasma.Codec.Data.Pane (Pane)
import Chiasma.Codec.Data.Session (Session (Session))
import Chiasma.Command.Pane (capturePane)
import Chiasma.Data.CodecError (CodecError)
import Chiasma.Data.Panes (Panes)
import Chiasma.Data.RenderError (RenderError)
import Chiasma.Data.TmuxCommand (TmuxCommand (KillServer, ListSessions))
import Chiasma.Data.TmuxError (TmuxError)
import Chiasma.Data.TmuxNative (TmuxNative (..))
import Chiasma.Effect.Codec (Codec, NativeCodecE)
import qualified Chiasma.Effect.TmuxApi as TmuxApi
import Chiasma.Effect.TmuxClient (NativeTmux, TmuxClient)
import Chiasma.Interpreter.Codec (interpretCodecPanes, interpretCodecTmuxCommand)
import Chiasma.Interpreter.TmuxClient (interpretTmuxNative)
import Chiasma.Path (pathText)
import qualified Chiasma.Test.Data.TmuxTestConfig as TmuxTestConfig
import Chiasma.Test.Data.TmuxTestConfig (TmuxTestConfig (TmuxTestConfig))
import Chiasma.Tmux (withTmux)

xtermArgs :: Int -> Int -> Int -> [Text]
xtermArgs width height fontSize =
  ["-geometry", show width <> "x" <> show height, "-fn", "xft:monospace:size=" <> show fontSize, "-e", "tmux"]

bashrcContent :: [Text]
bashrcContent =
  [
    "PS1='$ '"
  ]

createTmuxConf ::
  Member Test r =>
  Path Abs File ->
  [Text] ->
  Sem r (Path Abs File)
createTmuxConf wait content = do
  bashrc <- Test.tempFile bashrcContent [relfile|bashrc|]
  Test.tempFile (defaultContent bashrc ++ content ++ initCommands) [relfile|tmux.conf|]
  where
    defaultContent rc =
      [[exon|set -g default-command '/usr/bin/env bash --noprofile --rcfile #{pathText rc}'|]]
    initCommands =
      [
        [exon|run-shell -b 'touch #{pathText wait}'|]
      ]

testTmuxProcessConfig ::
  Members [Pty, Test, Embed IO] r =>
  Path Abs File ->
  TmuxTestConfig ->
  Path Abs File ->
  Sem r (ProcessConfig () () ())
testTmuxProcessConfig wait (TmuxTestConfig {..}) socket = do
  confFile <- createTmuxConf wait conf
  Pty.resize width height
  handle <- Pty.handle
  let
    tmuxArgs =
      ["-S", pathText socket, "-f", pathText confFile]
    prc =
      if gui
      then proc "xterm" (toString <$> xtermArgs (fromIntegral width) (fromIntegral height) fontSize ++ tmuxArgs)
      else proc "tmux" (toString <$> tmuxArgs)
  pure (stdio (useHandleClose handle) prc)
  where
    stdio (s :: ∀ st . StreamSpec st ()) =
      setStdin s . setStdout s . setStderr s

waitForServer ::
  ∀ enc dec t d r .
  Members [Scoped_ (TmuxClient enc dec) !! TmuxError, Codec TmuxCommand enc dec !! CodecError, Time t d] r =>
  Sem r ()
waitForServer =
  Time.while (MilliSeconds 10) do
    resumeAs @CodecError @(Codec _ _ _) True $ resumeAs @TmuxError @(Scoped_ _) True $ withTmux do
      s <- [] <! TmuxApi.send ListSessions
      pure (s /= [Session 0 "0"])

waitForEmptyPrompt ::
  ∀ enc dec t d r .
  Members [Scoped_ (TmuxClient enc dec) !! TmuxError, Codec TmuxCommand enc dec !! CodecError, Time t d] r =>
  Sem r ()
waitForEmptyPrompt =
  Time.while (MilliSeconds 10) do
    resumeAs @CodecError @(Codec _ _ _) True $ resumeAs @TmuxError @(Scoped_ _) True $ withTmux do
      prompt <- [] <! capturePane 0
      pure (["$"] /= prompt)

waitForFile ::
  Members [Time t d, Embed IO] r =>
  Path Abs File ->
  Sem r ()
waitForFile file =
  Time.while (MilliSeconds 10) do
    not <$> doesFileExist file

runAndKillTmux ::
  ∀ err enc dec t d r a .
  Members [Scoped_ (TmuxClient enc dec) !! TmuxError, Codec TmuxCommand enc dec !! CodecError] r =>
  Members [SystemProcess !! err, Time t d, Log, Resource, Error Text, Race, Embed IO] r =>
  Bool ->
  Sem r a ->
  Sem r a
runAndKillTmux waitForPrompt thunk = do
  void (Race.timeout (throw "tmux didn't create sessions") (Seconds 3) waitForServer)
  when waitForPrompt do
    void (Race.timeout (throw "empty prompt did not appear in pane 0") (Seconds 3) waitForEmptyPrompt)
  result <- finally thunk do
    resumeWith @_ @(Scoped_ _) (withTmux (resume_ (TmuxApi.send KillServer))) (Log.error "failed to kill server")
    resume_ SystemProcess.kill
  result <$ resume_ (void SystemProcess.wait)

type TestTmuxEffects =
  [
    NativeTmux,
    NativeTmux !! TmuxError,
    NativeCodecE TmuxCommand,
    NativeCodecE (Panes Pane),
    Reader TmuxNative
  ]

withTestTmux ::
  Members [Test, Time t d, Log, Resource, Stop TmuxError, Error Text, Race, Async, Embed IO] r =>
  TmuxTestConfig ->
  Sem (TestTmuxEffects ++ r) a ->
  Path Abs Dir ->
  Sem r a
withTestTmux tConf@TmuxTestConfig {waitForPrompt} thunk tempDir = do
  let socket = tempDir </> [relfile|tmux_socket|]
  let wait = tempDir </> [relfile|wait|]
  exe <- fromEither =<< resolveExecutable [relfile|tmux|] Nothing
  interpretPty $ resumeHoistError @_ @(Scoped_ _) show $ withPty do
    pc <- testTmuxProcessConfig wait tConf socket
    interpretSystemProcessNativeOpaqueSingle pc $ runReader (TmuxNative exe (Just socket)) do
      void $ Race.timeout (throw "tmux didn't start") (Seconds 3) (waitForFile wait)
      interpretCodecPanes $ interpretCodecTmuxCommand $ interpretTmuxNative $ restop @TmuxError do
        runAndKillTmux waitForPrompt (insertAt @5 thunk)

withTempDir ::
  Members [Resource, Embed IO] r =>
  Path Abs Dir ->
  (Path Abs Dir -> Sem r a) ->
  Sem r a
withTempDir targetDir =
  bracket
    (createTempDir targetDir "chiasma-test")
    (tryAny . removeDirRecur)

withSystemTempDir ::
  Members [Resource, Embed IO] r =>
  (Path Abs Dir -> Sem r a) ->
  Sem r a
withSystemTempDir f = do
  targetDir <- getTempDir
  withTempDir targetDir f

type TestStack =
  TestTmuxEffects ++ [
    ChronosTime,
    Log,
    Stop CodecError,
    Error CodecError,
    Stop RenderError,
    Error RenderError,
    Stop TmuxError,
    Error TmuxError,
    Error Text,
    Race,
    Async,
    Test,
    Fail,
    Error TestError,
    Hedgehog IO,
    Error Failure,
    Embed IO,
    Resource,
    Final IO
  ]

testTime :: Chronos.Time
testTime =
  datetimeToTime (mkDatetime 2030 3 20 12 0 0)

runTmuxTest ::
  TmuxTestConfig ->
  Sem TestStack a ->
  TestT IO a
runTmuxTest conf thunk =
  runTestAuto $
  asyncToIOFinal $
  interpretRace $
  mapError TestError $
  mapError show $
  stopToError $
  mapError @RenderError @Text show $
  stopToError $
  mapError @CodecError @Text show $
  stopToError $
  interpretLogStdoutLevelConc (Just (TmuxTestConfig.logLevel conf)) $
  interpretTimeChronos do
    withSystemTempDir (withTestTmux conf thunk)

tmuxTest ::
  Sem TestStack a ->
  TestT IO a
tmuxTest =
  runTmuxTest def

tmuxTestTrace ::
  Sem TestStack a ->
  TestT IO a
tmuxTestTrace =
  runTmuxTest def { TmuxTestConfig.logLevel = Trace }

tmuxGuiTest ::
  Sem TestStack a ->
  TestT IO a
tmuxGuiTest =
  runTmuxTest def { TmuxTestConfig.gui = True }

tmuxGuiTestTrace ::
  Sem TestStack a ->
  TestT IO a
tmuxGuiTestTrace =
  runTmuxTest def { TmuxTestConfig.gui = True, TmuxTestConfig.logLevel = Trace  }