jupyter-0.9.0: tests/Jupyter/Test/Install.hs
{-|
Module : Jupyter.Test.Install
Description : Tests for the Jupyter.Install module.
Copyright : (c) Andrew Gibiansky, 2016
License : MIT
Maintainer : andrew.gibiansky@gmail.com
Stability : stable
Portability : POSIX
-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
module Jupyter.Test.Install (installTests) where
-- Imports from 'base'
import Control.Monad (forM_)
import Data.List (isInfixOf)
import Data.Maybe (fromMaybe)
import Data.Proxy (Proxy(..))
import System.Environment (setEnv, lookupEnv)
import System.IO (stderr, stdout)
-- Imports from 'directory'
import System.Directory (setPermissions, getPermissions, Permissions(..), canonicalizePath,
createDirectoryIfMissing, removeFile, doesFileExist)
-- Imports from 'bytestring'
import qualified Data.ByteString.Char8 as CBS
-- Imports from 'aeson'
import Data.Aeson (decodeStrict, Value)
-- Imports from 'text'
import qualified Data.Text as T
-- Imports from 'extra'
import Control.Exception.Extra (bracket)
import System.IO.Extra (withTempDir)
-- Imports from 'silently'
import System.IO.Silently (hCapture_)
-- Imports from 'tasty'
import Test.Tasty (TestTree, testGroup)
-- Imports from 'tasty-hunit'
import Test.Tasty.HUnit (testCase, (@=?), assertFailure, assertBool)
-- Imports from 'jupyter'
import Jupyter.Install.Internal
import Jupyter.Test.Utils (shouldThrow, inTempDir)
import qualified Jupyter.Install.Internal as I
installTests :: TestTree
installTests = testGroup "Install Tests"
[ testVersionNumberParsing
, testVersionNumberPrinting
, testFindingJupyterExecutable
, testJupyterVersionReading
, testStderrIsUntouched
, testCorrectJupyterVersionsAccepted
, testKernelspecFilesCreated
, testEndToEndInstall
]
-- | Test that version numbers from jupyter --version are properly parsed.
testVersionNumberParsing :: TestTree
testVersionNumberParsing = testCase "Version number parsing" $ do
Just (I.JupyterVersion 10 1 0) @=? I.parseVersion "10.1.0"
Just (I.JupyterVersion 4 1 2000) @=? I.parseVersion "4.1.2000"
Just (I.JupyterVersion 4 1 0) @=? I.parseVersion "4.1"
Just (I.JupyterVersion 4 0 0) @=? I.parseVersion "4"
Nothing @=? I.parseVersion ".xx.4"
Nothing @=? I.parseVersion "4.1.2.1.2"
-- | Test that version numbers from jupyter --version are properly printed to the user.
testVersionNumberPrinting :: TestTree
testVersionNumberPrinting = testCase "Version number printing" $ do
parseThenShow "10.1.0"
parseThenShow "4.1.200"
parseThenShow "4.1.0"
where
parseThenShow str =
Just str @=? (I.showVersion <$> I.parseVersion str)
-- | Set the PATH variable to a particular value during execution of an IO action.
withPath :: String -> IO a -> IO a
withPath newPath action =
bracket resetPath (setEnv "PATH") (const action)
where
resetPath = do
path <- lookupEnv "PATH"
setEnv "PATH" newPath
return $ fromMaybe "" path
-- | Test that `jupyter` is found by `which` if it is on the PATH, and isn't found if its not on the
-- path or isn't executable. Ensures that all returned paths are absolute and canonical.
testFindingJupyterExecutable :: TestTree
testFindingJupyterExecutable = testCase "PATH searching" $
-- Run the entire test in a temporary directory.
inTempDir $ \tmp ->
-- Set up a PATH that has both relative and absolute paths.
withPath (".:test-path/twice:" ++ tmp ++ "/test-path-2") $
-- For each possible location test executable finding.
forM_ [".", "test-path/twice", "test-path-2"] $ \prefix -> do
let path = prefix ++ "/jupyter"
createDirectoryIfMissing True prefix
-- When the file doesn't exist it should not be found.
which "jupyter" `shouldThrow` (Proxy :: Proxy JupyterKernelspecException)
-- When the file is not executable it should not be found.
writeFile path "#!/bin/bash\ntrue"
which "jupyter" `shouldThrow` (Proxy :: Proxy JupyterKernelspecException)
-- When the file is executable, it should be found, and be an absolute path
-- that ultimately resolves to what we expect.
setExecutable path
jupyterLoc <- which "jupyter"
expectedLoc <- canonicalizePath $ tmp ++ "/" ++ prefix ++ "/jupyter"
expectedLoc @=? jupyterLoc
-- Clean up to avoid messing with future tests.
removeFile path
-- | Test that the install script can parse the version numbers output by Jupyter.
testJupyterVersionReading :: TestTree
testJupyterVersionReading = testCase "jupyter --version parsing" $
inTempDir $ \_ ->
-- Set up a jupyter executable that outputs what we expect.
withPath "." $ do
writeMockJupyter ""
setExecutable "jupyter"
path <- which "jupyter"
-- Version too low.
writeMockJupyter "1.2.0"
verifyJupyterCommand path `shouldThrow` (Proxy :: Proxy JupyterKernelspecException)
-- Could not parse output.
writeMockJupyter "..."
verifyJupyterCommand path `shouldThrow` (Proxy :: Proxy JupyterKernelspecException)
writeMockJupyter "asdf"
verifyJupyterCommand path `shouldThrow` (Proxy :: Proxy JupyterKernelspecException)
-- Works.
writeMockJupyter "3.0.0"
verifyJupyterCommand path
writeMockJupyter "4.1.4000"
verifyJupyterCommand path
-- | Create a mock 'jupyter' command, which always outputs a particular string to stdout and exits
-- with exit code zero.
writeMockJupyter :: String -> IO ()
writeMockJupyter out = writeMockJupyter' out "" 0
-- | Create a mock 'jupyter' command, which outputs given strings to stdout and stderr and exits
-- with the provided exit code.
writeMockJupyter' :: String -- ^ What to output on stdout
-> String -- ^ What to output on stderr
-> Int -- ^ What exit code to exit with
-> IO ()
writeMockJupyter' stdoutOut stderrOut errCode =
writeFile "jupyter" $
unlines
[ "#!/bin/bash"
, "echo -n \"" ++ stdoutOut ++ "\""
, "echo -n \"" ++ stderrOut ++ "\" >/dev/stderr"
, "exit " ++ show errCode
]
testStderrIsUntouched :: TestTree
testStderrIsUntouched = testCase "stderr is piped through" $
inTempDir $ \_ ->
-- Set up a jupyter executable that outputs something to stderr.
withPath "." $ do
let msg = "An error"
writeMockJupyter' "Some output" msg 0
setExecutable "jupyter"
-- Check that stderr goes through as usual.
stderrOut <- hCapture_ [stderr] (runJupyterCommand "jupyter" [])
msg @=? stderrOut
-- Check that stdout of the command is not output but is captured.
writeMockJupyter' "stdout" "" 0
stdoutOut <- hCapture_ [stdout] (runJupyterCommand "jupyter" [])
"" @=? stdoutOut
testCorrectJupyterVersionsAccepted :: TestTree
testCorrectJupyterVersionsAccepted = testCase "Correct jupyter versions accepted" $ do
assertBool "Version 3 supported" $ jupyterVersionSupported $ JupyterVersion 3 0 0
assertBool "Version 3.1 supported" $ jupyterVersionSupported $ JupyterVersion 3 1 0
assertBool "Version 4 supported" $ jupyterVersionSupported $ JupyterVersion 4 0 0
assertBool "Version 10 supported" $ jupyterVersionSupported $ JupyterVersion 10 0 0
assertBool "Version 2.3 not supported" $ not $ jupyterVersionSupported $ JupyterVersion 2 3 0
assertBool "Version 1.0 not supported" $ not $ jupyterVersionSupported $ JupyterVersion 1 0 0
testKernelspecFilesCreated :: TestTree
testKernelspecFilesCreated = testCase "kernelspec files created" $
inTempDir $ \tmp -> do
kernelspec <- createTestKernelspec tmp
-- Test that all required files are created
withTempDir $ \kernelspecDir -> do
prepareKernelspecDirectory kernelspec kernelspecDir
assertBool "kernel.js not copied" =<< doesFileExist (kernelspecDir ++ "/kernel.js")
assertBool "logo-64x64.png not copied" =<< doesFileExist (kernelspecDir ++ "/logo-64x64.png")
assertBool "kernel.json not created" =<< doesFileExist (kernelspecDir ++ "/kernel.json")
-- Test that the file is valid JSON and {connection_file} is present.
withTempDir $ \kernelspecDir -> do
prepareKernelspecDirectory kernelspec kernelspecDir
kernelJson <- readFile (kernelspecDir ++ "/kernel.json")
assertBool "{connection_file} not found" $ "\"{connection_file}\"" `isInfixOf` kernelJson
case decodeStrict (CBS.pack kernelJson) :: Maybe Value of
Nothing -> assertFailure "Could not decode kernel.json file as JSON"
Just _ -> return ()
-- Test that all previously-existing files are gone
withTempDir $ \kernelspecDir -> do
let prevFile1 = kernelspecDir ++ "/tmp.file"
prevFile2 = kernelspecDir ++ "/kernel.js"
writeFile prevFile1 "test1"
writeFile prevFile2 "test2"
prepareKernelspecDirectory kernelspec { kernelspecJsFile = Nothing } kernelspecDir
assertBool "previous file still exists" =<< fmap not (doesFileExist prevFile1)
assertBool "previous kernel.js file still exists" =<< fmap not (doesFileExist prevFile2)
-- Test that end-to-end installs work as expected, and call the 'jupyter kernelspec install'
-- in the way that they are expected to.
testEndToEndInstall :: TestTree
testEndToEndInstall = testCase "installs end-to-end" $
inTempDir $ \tmp -> do
kernelspec <- createTestKernelspec tmp
withPath "." $ do
writeFile "jupyter" $ jupyterScript True
setExecutable "jupyter"
result1 <- installKernel InstallLocal kernelspec
case result1 of
InstallFailed msg -> assertFailure $ "Failed to install kernelspec: " ++ T.unpack msg
_ -> return ()
writeFile "jupyter" $ jupyterScript False
result2 <- installKernel InstallGlobal kernelspec
case result2 of
InstallFailed msg -> assertFailure $ "Failed to install kernelspec: " ++ T.unpack msg
_ -> return ()
where
jupyterScript user =
unlines
[ "#!/bin/bash"
, "if [[ $1 == \"--version\" ]]; then"
, "echo 4.1.0"
, "else"
, "[[ \"$1 $2 $4\" == \"kernelspec install --replace\" ]] || exit 1"
, if user
then "[[ \"$5\" == \"--user\" ]] || exit 0"
else ""
, "fi"
]
-- Create a kernelspec that refers to newly generated files in the provided directory.
createTestKernelspec :: String -> IO Kernelspec
createTestKernelspec tmp = do
let kernelJsFile = tmp ++ "/" ++ "kernel.js"
writeFile kernelJsFile "kernel.js"
let kernelLogoFile = tmp ++ "/" ++ "logo-64x64.png"
writeFile kernelLogoFile "logo-64x64.png"
return
Kernelspec
{ kernelspecDisplayName = "Test"
, kernelspecLanguage = "test"
, kernelspecCommand = \exe conn -> [exe, conn, "--test"]
, kernelspecJsFile = Just kernelJsFile
, kernelspecLogoFile = Just kernelLogoFile
, kernelspecEnv = mempty
}
-- | Make a file executable.
setExecutable :: FilePath -> IO ()
setExecutable path = do
perms <- getPermissions path
setPermissions path perms { executable = True }