packages feed

cradle-0.0.0.0: test/CradleSpec.hs

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -Wno-type-defaults #-}

module CradleSpec where

import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception
import Control.Monad (void, when)
import Control.Monad.Trans.Identity
import Cradle
import Data.ByteString (pack)
import Data.String.Conversions
import Data.String.Interpolate (i)
import Data.String.Interpolate.Util (unindent)
import Data.Text (Text)
import qualified Data.Text
import GHC.IO.Exception
import System.Directory
import System.Environment
import System.FilePath ((</>))
import System.IO (hClose, hGetContents, hIsClosed, hPutStrLn, stderr, stdout)
import System.IO.Silently
import System.Process (createPipe)
import Test.Hspec
import Test.Mockery.Directory
import Test.Mockery.Environment (withModifiedEnvironment)
import Prelude hiding (getContents, length)

spec :: Spec
spec = do
  around_ inTempDirectory $ do
    it "runs simple commands" $ do
      writePythonScript "exe" "open('file', 'w').write('')"
      run_ $ cmd "./exe"
      doesFileExist "file" `shouldReturn` True

    describe "exceptions" $ do
      it "throws when executable cannot be found" $ do
        run_ (cmd "./exe")
          `shouldThrow` ( \(e :: IOException) ->
                            ioe_filename e == Just "./exe"
                              && ioe_type e == NoSuchThing
                        )

      it "throws when the executable flag isn't set" $ do
        writeFile "exe" "whatever"
        run_ (cmd "./exe")
          `shouldThrow` ( \(e :: IOException) ->
                            ioe_filename e == Just "./exe"
                              && ioe_type e == PermissionDenied
                        )

      it "throws when the hashbang interpreter cannot be found" $ do
        writeFile "exe" "#!/does/not/exist\nfoo"
        makeExecutable "exe"
        run_ (cmd "./exe")
          `shouldThrow` ( \(e :: IOException) ->
                            ioe_filename e == Just "./exe"
                              && ioe_type e == NoSuchThing
                        )

    it "runs in MonadIO contexts" $ do
      writePythonScript "exe" "pass"
      runIdentityT $ do
        _ :: StdoutUntrimmed <- run $ cmd "./exe"
        run_ $ cmd "./exe"

    describe "arguments" $ do
      let writeArgumentWriter = do
            writePythonScript
              "exe"
              "open('file', 'w').write(' '.join(sys.argv[1:]))"

      it "allows passing in an argument" $ do
        writeArgumentWriter
        run_ $
          cmd "./exe"
            & addArgs ["foo"]
        readFile "file" `shouldReturn` "foo"

      it "allows passing in multiple arguments" $ do
        writeArgumentWriter
        run_ $
          cmd "./exe"
            & addArgs ["foo", "bar"]
        readFile "file" `shouldReturn` "foo bar"

      it "allows splitting strings in haskell" $ do
        StdoutUntrimmed output <-
          run $
            cmd "printf"
              & addArgs (words "%s.%s foo bar")
        output `shouldBe` cs "foo.bar"

      it "allows Text as arguments" $ do
        StdoutTrimmed output <-
          run $
            cmd "echo"
              & addArgs [cs "foo" :: Text]
        output `shouldBe` cs "foo"

    describe "providing stdin" $ do
      describe "using handles" $ do
        it "allows passing in a handle for stdin" $ do
          writePythonScript "exe" "print(sys.stdin.read().strip())"
          (readEnd, writeEnd) <- createPipe
          _ <- forkIO $ do
            hPutStrLn writeEnd "test stdin"
            hClose writeEnd
          StdoutUntrimmed output <-
            run $
              cmd "./exe"
                & setStdinHandle readEnd
          output `shouldBe` cs "test stdin\n"

      it "allows disabling inheriting stdin" $ do
        writePythonScript "exe" "print(sys.stdin)"
        StdoutTrimmed output <- run $ cmd "./exe" & setNoStdin
        output `shouldBe` cs "None"

    describe "capturing stdout" $ do
      it "allows capturing stdout" $ do
        writePythonScript "exe" "print('output')"
        StdoutUntrimmed stdout <- run $ cmd "./exe"
        stdout `shouldBe` cs "output\n"

      it "allows capturing stdout *and* pass in arguments" $ do
        writePythonScript "exe" "print(' '.join(sys.argv[1:]))"
        StdoutUntrimmed output <-
          run $
            cmd "./exe"
              & addArgs ["foo"]
        output `shouldBe` cs "foo\n"
        StdoutUntrimmed output <- run $ cmd "./exe" & addArgs ["foo", "bar"]
        output `shouldBe` cs "foo bar\n"

      it "relays stdout when it's not captured (by default)" $ do
        writePythonScript "exe" "print('foo')"
        output <- capture_ $ run_ $ cmd "./exe"
        output `shouldBe` "foo\n"

      it "allows silencing stdout when it's not captured" $ do
        writePythonScript "exe" "print('foo')"
        output <- hCapture_ [stdout] $ run_ $ cmd "./exe" & silenceStdout
        output `shouldBe` ""

      it "does not relay stdout when it's captured (by default)" $ do
        writePythonScript "exe" "print('foo')"
        output <- capture_ $ do
          StdoutUntrimmed _ <- run $ cmd "./exe"
          return ()
        output `shouldBe` ""

      describe "StdoutTrimmed" $ do
        it "allows capturing the stripped stdout" $ do
          writePythonScript "exe" "print('foo')"
          StdoutTrimmed output <- run $ cmd "./exe" & addArgs ["foo"]
          output `shouldBe` cs "foo"

        it "strips leading and trailing spaces and newlines" $ do
          writePythonScript "exe" "print('  foo   ')"
          StdoutTrimmed output <- run $ cmd "./exe" & addArgs ["foo"]
          output `shouldBe` cs "foo"

      it "handles bigger outputs correctly" $ do
        writePythonScript "exe" "print('x' * 2 ** 22)"
        StdoutTrimmed output <- run $ cmd "./exe"
        Data.Text.length output `shouldBe` 2 ^ (22 :: Int)

      it "allows capturing binary data" $ do
        writePythonScript "exe" "sys.stdout.buffer.write(bytearray([0, 1, 2, 3]))"
        StdoutRaw output <- run $ cmd "./exe"
        output `shouldBe` pack [0, 1, 2, 3]

      it "uses the replacement character when capturing invalid utf-8 as Text" $ do
        writePythonScript "exe" "sys.stdout.buffer.write(b'foo-\\x80-bar')"
        StdoutUntrimmed output <- run $ cmd "./exe"
        output `shouldBe` cs "foo-�-bar"

      describe "using handles" $ do
        it "allows sending stdout to a handle" $ do
          writePythonScript "exe" "print('foo')"
          (readEnd, writeEnd) <- createPipe
          run_ $ cmd "./exe" & addStdoutHandle writeEnd
          hClose writeEnd
          hGetContents readEnd `shouldReturn` cs "foo\n"

        it "does not relay stdout when it's captured (by default)" $ do
          writePythonScript "exe" "print('foo')"
          (_readEnd, writeEnd) <- createPipe
          stdout <- capture_ $ run_ $ cmd "./exe" & addStdoutHandle writeEnd
          hClose writeEnd
          stdout `shouldBe` ""

        it "doesn't close the handle after running the process" $ do
          writePythonScript "exe" "print(sys.argv[1])"
          (readEnd, writeEnd) <- createPipe
          run_ $
            cmd "./exe"
              & addArgs ["foo"]
              & addStdoutHandle writeEnd
          hIsClosed writeEnd `shouldReturn` False
          hIsClosed readEnd `shouldReturn` False
          run_ $
            cmd "./exe"
              & addArgs ["bar"]
              & addStdoutHandle writeEnd
          hClose writeEnd
          hGetContents readEnd `shouldReturn` cs "foo\nbar\n"

        it "allows capturing stdout *and* passing in a stdout handle" $ do
          writePythonScript "exe" "print('foo')"
          (readEnd, writeEnd) <- createPipe
          StdoutUntrimmed output <-
            run $
              cmd "./exe"
                & addStdoutHandle writeEnd
          hClose writeEnd
          handleOutput <- hGetContents readEnd
          (output, handleOutput) `shouldBe` (cs "foo\n", "foo\n")

        it "allows specifying multiple stdout handles" $ do
          writePythonScript "exe" "print('foo')"
          (readEnd1, writeEnd1) <- createPipe
          (readEnd2, writeEnd2) <- createPipe
          run_ $
            cmd "./exe"
              & addStdoutHandle writeEnd1
              & addStdoutHandle writeEnd2
          hClose writeEnd1
          hClose writeEnd2
          output1 <- hGetContents readEnd1
          output2 <- hGetContents readEnd2
          (output1, output2) `shouldBe` ("foo\n", "foo\n")

    describe "capture stderr" $ do
      it "allows capturing stderr" $ do
        writePythonScript "exe" "print('output', file=sys.stderr)"
        StderrRaw stderr <- run $ cmd "./exe"
        stderr `shouldBe` cs "output\n"

      it "relays stderr when it's not captured (by default)" $ do
        writePythonScript "exe" "print('foo', file=sys.stderr)"
        output <- hCapture_ [stderr] $ run_ $ cmd "./exe"
        output `shouldBe` "foo\n"

      it "allows silencing stderr when it's not captured" $ do
        writePythonScript "exe" "print('foo', file=sys.stderr)"
        output <- hCapture_ [stderr] $ run_ $ cmd "./exe" & silenceStderr
        output `shouldBe` ""

      it "does not relay stderr when it's captured (by default)" $ do
        writePythonScript "exe" "print('foo', file=sys.stderr)"
        output <- hCapture_ [stderr] $ do
          StderrRaw _ <- run $ cmd "./exe"
          return ()
        output `shouldBe` ""

      describe "using handles" $ do
        it "allows sending stderr to a handle" $ do
          writePythonScript "exe" "print('foo', file=sys.stderr)"
          (readEnd, writeEnd) <- createPipe
          run_ $
            cmd "./exe"
              & addStderrHandle writeEnd
          hClose writeEnd
          hGetContents readEnd `shouldReturn` cs "foo\n"

        it "does not relay stderr when it's captured (by default)" $ do
          writePythonScript "exe" "print('foo', file=sys.stderr)"
          (_readEnd, writeEnd) <- createPipe
          stderr <- hCapture_ [stderr] $ run_ $ cmd "./exe" & addStderrHandle writeEnd
          hClose writeEnd
          stderr `shouldBe` ""

        it "doesn't close the handle after running the process" $ do
          writePythonScript "exe" "print(sys.argv[1], file=sys.stderr)"
          (readEnd, writeEnd) <- createPipe
          run_ $
            cmd "./exe"
              & addStderrHandle writeEnd
              & addArgs ["foo"]
          hIsClosed writeEnd `shouldReturn` False
          hIsClosed readEnd `shouldReturn` False
          run_ $
            cmd "./exe"
              & addStderrHandle writeEnd
              & addArgs ["bar"]
          hClose writeEnd
          hGetContents readEnd `shouldReturn` cs "foo\nbar\n"

        it "allows writing a wrapper that captures stderr" $ do
          let wrapper :: Output output => ProcessConfiguration -> IO output
              wrapper pc = do
                (exitCode, StderrRaw err, o) <- run pc
                when (exitCode /= ExitSuccess) $ do
                  throwIO $ ErrorCall $ "stderr:\n" <> cs err
                return o
          writePythonScript "exe" "print('foo', file=sys.stderr)"
          (readEnd, writeEnd) <- createPipe
          StderrRaw output <-
            wrapper $
              cmd "./exe"
                & addStderrHandle writeEnd
          hClose writeEnd
          handleOutput <- hGetContents readEnd
          (output, handleOutput) `shouldBe` (cs "foo\n", "foo\n")
          writePythonScript "exe" "print('foo', file=sys.stderr); sys.exit(42)"
          try (wrapper $ cmd "./exe" :: IO ())
            `shouldReturn` Left (ErrorCall "stderr:\nfoo\n")

    it "allows capturing both stdout and stderr" $ do
      writePythonScript "exe" "print('out') ; print('err', file=sys.stderr)"
      (StdoutUntrimmed out, StderrRaw err) <- run $ cmd "./exe"
      (out, err) `shouldBe` (cs "out\n", cs "err\n")

    describe "exitcodes" $ do
      it "throws when the exitcode is not 0" $ do
        writePythonScript "exe" "sys.exit(42)"
        run_ (cmd "./exe")
          `shouldThrowShow` "command failed with exitcode 42: ./exe"

      it "doesn't throw when the exitcode is captured" $ do
        writePythonScript "exe" "sys.exit(42)"
        exitCode <- run $ cmd "./exe"
        exitCode `shouldBe` ExitFailure 42

      it "captures success exitcodes" $ do
        writePythonScript "exe" "pass"
        run (cmd "./exe") `shouldReturn` ExitSuccess

      it "allows capturing exitcodes and stdout" $ do
        writePythonScript "exe" "print('foo'); sys.exit(42)"
        (exitCode, StdoutTrimmed stdout) <- run $ cmd "./exe"
        stdout `shouldBe` cs "foo"
        exitCode `shouldBe` ExitFailure 42

    describe "working directory" $ do
      it "inherits the current working directory" $ do
        writePythonScript "exe" "print(os.getcwd())"
        StdoutTrimmed stdout <- run $ cmd "./exe"
        cwd <- getCurrentDirectory
        stdout `shouldBe` cs cwd

      it "allows setting the working directory" $ do
        writePythonScript "exe" "print(os.getcwd())"
        createDirectory "dir"
        cwd <- getCurrentDirectory
        StdoutTrimmed stdout <-
          run $
            cmd (cwd </> "./exe")
              & setWorkingDir "dir"
        stdout `shouldBe` cs (cwd </> "dir")

      it "does not modify the parent's working directory" $ do
        writePythonScript "exe" "print('foo')"
        createDirectory "dir"
        before <- getCurrentDirectory
        StdoutTrimmed output <-
          run $
            cmd (before </> "./exe")
              & setWorkingDir "dir"
        after <- getCurrentDirectory
        output `shouldBe` cs "foo"
        after `shouldBe` before

    describe "when receiving an async exception" $ do
      it "sends a signal to the child process" $ do
        writePythonScript "exe" $
          unindent
            [i|
              import signal
              import time
              import os

              def handler(signal, stack_frame):
                open('received-signal', 'a').write(f'received signal: {signal}')
                exit(0)

              signal.signal(signal.SIGTERM, handler)
              open('running', 'a').write('')
              time.sleep(3)
            |]
        thread <- forkIO $ run_ $ cmd "./exe"
        waitFor $ void $ readFile "running"
        killThread thread
        waitFor $ do
          readFile "received-signal" `shouldReturn` "received signal: 15"

    describe "environment" $ do
      it "inherits the environment (by default)" $ do
        withModifiedEnvironment [("TEST_ENV_VAR", "foo")] $ do
          writePythonScript "exe" "print(os.environ.get('TEST_ENV_VAR'))"
          StdoutTrimmed stdout <- run $ cmd "./exe"
          stdout `shouldBe` cs "foo"

      describe "modifyEnvVar" $ do
        it "allows setting environment variables" $ do
          writePythonScript "exe" "print(os.environ.get('TEST_ENV_VAR'))"
          StdoutTrimmed stdout <-
            run $
              cmd "./exe"
                & modifyEnvVar "TEST_ENV_VAR" (const $ Just "foo")
          stdout `shouldBe` cs "foo"

        it "doesn't overwrite existing environment variables" $ do
          withModifiedEnvironment [("TEST_ENV_VAR", "foo")] $ do
            writePythonScript "exe" "print(os.environ.get('TEST_ENV_VAR'))"
            StdoutTrimmed stdout <-
              run $
                cmd "./exe"
                  & modifyEnvVar "OTHER_ENV_VAR" (const $ Just "bar")
            stdout `shouldBe` cs "foo"

        it "allows modifying environment variables" $ do
          withModifiedEnvironment [("TEST_ENV_VAR", "foo"), ("OTHER_VAR", "bar")] $ do
            writePythonScript "exe" $
              unindent
                [i|
                  print(os.environ.get('TEST_ENV_VAR'))
                  print(os.environ.get('OTHER_VAR'))
                |]
            StdoutTrimmed stdout <-
              run $
                cmd "./exe"
                  & modifyEnvVar "TEST_ENV_VAR" (fmap reverse)
            stdout `shouldBe` cs "oof\nbar"

        it "allows removing environment variables" $ do
          withModifiedEnvironment [("TEST_ENV_VAR", "foo")] $ do
            writePythonScript "exe" "print(os.environ.get('TEST_ENV_VAR'))"
            StdoutTrimmed stdout <-
              run $
                cmd "./exe"
                  & modifyEnvVar "TEST_ENV_VAR" (const Nothing)
            stdout `shouldBe` cs "None"

        it "allows setting multiple environment variables" $ do
          writePythonScript "exe" $
            unindent
              [i|
                print(os.environ.get('TEST_ENV_VAR'))
                print(os.environ.get('OTHER_VAR'))
              |]
          StdoutTrimmed stdout <-
            run $
              cmd "./exe"
                & modifyEnvVar "TEST_ENV_VAR" (const $ Just "foo")
                & modifyEnvVar "OTHER_VAR" (const $ Just "bar")
          stdout `shouldBe` cs "foo\nbar"

        it "allows modifying environment variables multiple times" $ do
          writePythonScript "exe" "print(os.environ.get('TEST_ENV_VAR'))"
          StdoutTrimmed stdout <-
            run $
              cmd "./exe"
                & modifyEnvVar "TEST_ENV_VAR" (const $ Just "foo")
                & modifyEnvVar "TEST_ENV_VAR" (fmap reverse)
          stdout `shouldBe` cs "oof"

writePythonScript :: FilePath -> String -> IO ()
writePythonScript file code = do
  pythonPath <- getEnv "PYTHON_BIN_PATH"
  writeFile file ("#!" <> pythonPath <> "\nimport os\nimport sys\n" <> code)
  makeExecutable file

makeExecutable :: FilePath -> IO ()
makeExecutable file = do
  permissions <- getPermissions file
  setPermissions file $ setOwnerExecutable True permissions

shouldThrowShow :: IO a -> String -> IO ()
shouldThrowShow action expected = do
  result <-
    (action >> return Nothing)
      `catch` (\(e :: SomeException) -> return $ Just e)
  case result of
    Nothing -> fail $ "action didn't throw: " <> expected
    Just e -> show e `shouldBe` expected

waitFor :: IO () -> IO ()
waitFor action = go 10
  where
    go n = do
      mException :: Either SomeException () <- try action
      case mException of
        Right () -> return ()
        Left exception ->
          if n <= 0
            then throwIO $ ErrorCall $ "waitFor timed out: " <> show exception
            else do
              threadDelay 100000
              go (n - 1)