packages feed

streamly-0.9.0: test/Streamly/Test/FileSystem/Event/Common.hs

{-# OPTIONS_GHC -Wno-deprecations #-}

-- |
-- Module      : Streamly.Test.FileSystem.Event.Common
-- Copyright   : (c) 2020 Composewell Technologies
-- License     : BSD-3-Clause
-- Maintainer  : streamly@composewell.com
-- Stability   : experimental
-- Portability : GHC
--
module Streamly.Test.FileSystem.Event.Common
    ( TestDesc

    -- * Running tests
    , runTests
    , WatchRootType (..)

    -- * Event predicates
    , dirEvent
    , fileEvent

    -- * Tests
    , dirCreate
    , dirCreateWithParent
    , dirDelete
    , dirMove
    , rootDirMove

    , fileCreate
    , fileCreateWithParent
    , fileDelete
    , fileModify
    , fileMove
    , rootFileMove

    , commonTests
    , commonRecTests
    )

where

import Debug.Trace (trace)
import Control.Concurrent (MVar, newEmptyMVar, putMVar, takeMVar, threadDelay)
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO)
import Data.Bifunctor (first)
import Data.Function ((&))
import Data.Functor.Identity (runIdentity)
import Data.List.NonEmpty (NonEmpty)
import Data.Word (Word8)
import Streamly.Data.Array (Array)
import System.Directory
    ( createDirectory
    , createDirectoryIfMissing
    , createDirectoryLink
    , removeFile
    , removePathForcibly
    , renameDirectory
    , renamePath
    )
import System.FilePath ((</>), takeDirectory)
import System.IO
    ( BufferMode(..), hSetBuffering, stdout, IOMode (WriteMode), openFile
    , hClose)
import System.IO.Temp (withSystemTempDirectory)

import qualified Data.List.NonEmpty as NonEmpty
import qualified Streamly.Internal.Data.Array as Array
import qualified Streamly.Internal.Data.Stream as Stream
import qualified Streamly.Internal.Data.Stream.Concurrent as Stream
import qualified Streamly.Unicode.Stream as Unicode

#if defined(FILESYSTEM_EVENT_LINUX)
import Streamly.Internal.FileSystem.Event.Linux (Event)
import qualified Streamly.Internal.FileSystem.Event.Linux as Event
#elif defined(FILESYSTEM_EVENT_DARWIN)
import Streamly.Internal.FileSystem.Event.Darwin (Event)
import qualified Streamly.Internal.FileSystem.Event.Darwin as Event
#elif defined(FILESYSTEM_EVENT_WINDOWS)
import Streamly.Internal.FileSystem.Event.Windows (Event)
import qualified Streamly.Internal.FileSystem.Event.Windows as Event
#else
import Streamly.Internal.FileSystem.Event (Event)
import qualified Streamly.Internal.FileSystem.Event as Event
#endif

import Test.Hspec

-------------------------------------------------------------------------------
-- Check generated events
-------------------------------------------------------------------------------

type EventChecker =
       FilePath  -- watch root
    -> FilePath  -- watch target, target dir if root is a symlink
    -> MVar ()   -- mvar to sync file system ops and the watch
    -> [(String, Event -> Bool)] -- expected events
    -> IO ()
type EventWatcher = NonEmpty (Array Word8) -> Stream.Stream IO Event.Event

eventMatches :: Event -> (String, Event -> Bool) -> Bool
eventMatches ev (expectedPath, f) =
    trace ("paths: " ++ show evPath ++ " " ++ show expectedPath)
        (evPath == expectedPath && f ev)

    where

    utf8ToString :: Array Word8 -> String
    utf8ToString =
        runIdentity . Stream.toList . Unicode.decodeUtf8' . Array.read

    evPath = utf8ToString (Event.getAbsPath ev)

toUtf8 :: MonadIO m => String -> m (Array Word8)
toUtf8 = Array.fromStream . Unicode.encodeUtf8' . Stream.fromList

checkEvents :: EventWatcher -> EventChecker
checkEvents watcher rootPath targetPath mvar matchList = do
    putStrLn ("Watching on root [" <> rootPath
             <> "] for [" <> targetPath <> "]")

    let matchList1 = fmap (first (targetPath </>)) matchList
        finder xs ev = filter (not . eventMatches ev) xs

    paths <- mapM toUtf8 [rootPath]
    watcher (NonEmpty.fromList paths)
        & Stream.before (putMVar mvar ())
        & Stream.trace (putStrLn . Event.showEvent)
        & Stream.scanl' finder matchList1
        & Stream.takeWhile (not . null)
        & Stream.drain

-------------------------------------------------------------------------------
-- Run tests
-------------------------------------------------------------------------------

type TestDesc =
    ( String            -- test description
    , FilePath -> IO () -- pre test operation, arg is path of the watch root.
    , FilePath -> IO () -- file system action, arg is path of the watch root.
    , [(String, Event -> Bool)] -- expected events (Absolute Path, predicate)
    )

-- | Whether watch root is a symlink and if so then what's the behavior
data WatchRootType =
      DirType             -- watch root is a directory
    | FileType            -- watch root is a file
    | SymLinkResolvedPath -- symlink to a directory,
                          -- Event contains path via the resolved dir
    | SymLinkOrigPath     -- symlink to a directory,
                          -- Event contains path via the original symlink
    deriving Show

driver :: EventChecker -> WatchRootType -> TestDesc -> SpecWith ()
driver checker symlinkStyle (desc, pre, ops, expected) =
    it desc $ runOneTest `shouldReturn` ()

    where

    fseventDir :: String
    fseventDir = "fsevent_dir"

    runOneTest = do
            sync <- newEmptyMVar
            withSystemTempDirectory fseventDir $ \fp -> do
                let root = fp </> "watch-root"
                target <-
                    case symlinkStyle of
                        DirType -> do
                            createDirectory root
                            return root
                        FileType -> do
                            openFile root WriteMode >>= hClose
                            return root
                        SymLinkResolvedPath -> do
                            let tgt = fp </> "watch-root-real"
                            createDirectory tgt
                            createDirectoryLink tgt root
                            return tgt
                        SymLinkOrigPath -> do
                            let tgt = fp </> "watch-root-real"
                            createDirectory tgt
                            createDirectoryLink tgt root
                            return root

                -- XXX On macOS we seem to get the watch root create events
                -- even though they occur before the watch is started.  Even if
                -- we add a delay here.
                startWatchAndCheck root target sync

    startWatchAndCheck root target sync = do
            pre root
            -- XXX On macOS the events from pre ops also seem to be bundled
            -- with the events occurred after the watch is started.
            let check = checker root target sync expected
                fsOps = Stream.fromEffect $ runFSOps root sync
            Stream.drain
                $ Stream.parListEagerFst [Stream.fromEffect check, fsOps]

    runFSOps fp sync = do
        -- We put the MVar before the event watcher starts to run but that does
        -- not ensure that the event watcher has actually started. So we need a
        -- delay as well. Do we?
        takeMVar sync >> threadDelay 200000
        ops fp
        threadDelay 10000000
        error "Time out occurred before event watcher could terminate"

-------------------------------------------------------------------------------
-- Test descriptions
-------------------------------------------------------------------------------

dirEvent, fileEvent :: (Event -> Bool) -> Event -> Bool

#if defined(FILESYSTEM_EVENT_DARWIN)
dirEvent f ev = Event.isDir ev && f ev
fileEvent f ev = Event.isFile ev && f ev
#elif defined(FILESYSTEM_EVENT_LINUX)
dirEvent f ev = Event.isDir ev && f ev
fileEvent f ev = not (Event.isDir ev) && f ev
#else
dirEvent = id
fileEvent = id
#endif

-- XXX Tests for root as a regular file instead of dir?
-- XXX Add symlink and hardlink tests
-- XXX Add exception condition tests

-------------------------------------------------------------------------------
-- Dir tests
-------------------------------------------------------------------------------

createParent :: FilePath -> FilePath -> IO ()
createParent file parent = do
    createDirectoryIfMissing True (parent </> takeDirectory file)

createDirWithParent :: FilePath -> FilePath -> IO ()
createDirWithParent dir parent =
    when (not (null dir)) $ createDirectoryIfMissing True (parent </> dir)

createDir :: FilePath -> FilePath -> IO ()
createDir dir parent =
    when (not (null dir)) $ createDirectory (parent </> dir)

-- | Create the dir along with its parent dir during the test. This is
-- especially useful to detect a race in Linux case where the watch for a
-- directory is added in event processing, so if the the child directory is
-- created before the watch was installed then we may miss some events.
dirCreateWithParent ::
    String -> (String -> [([Char], Event -> Bool)]) -> TestDesc
dirCreateWithParent dir events =
    ( "dir created (" ++ dir ++ ")"
    , const (return ())
    , createDirWithParent dir
    , events dir
    )

dirCreate :: String -> (String -> [([Char], Event -> Bool)]) -> TestDesc
dirCreate dir events =
    ( "dir created (" ++ dir ++ ")"
    , createParent dir
    , createDir dir
    , events dir
    )

dirDelete :: String -> (String -> [([Char], Event -> Bool)]) -> TestDesc
dirDelete dir events =
      ( "dir deleted (" ++ dir ++ ")"
      , createDirWithParent dir
      , \fp -> removePathForcibly (fp </> dir)
      , events dir
      )

dirMove ::
       String
    -> String
    -> (String -> String -> [(String, Event -> Bool)])
    -> TestDesc
dirMove dir1 dir2 events =
      ( "dir moved (" ++ dir1 ++ " " ++ dir2 ++ ")"
      , createDirWithParent dir1
      , \fp -> renameDirectory (fp </> dir1) (fp </> dir2)
      , events dir1 dir2
      )

rootDirMove ::
       String
    -> (String -> [(String, Event -> Bool)])
    -> TestDesc
rootDirMove suffix events =
      ( "root dir moved" ++ "(" ++ suffix ++ ")"
      , const (return ())
      , \fp -> renameDirectory fp (fp <> suffix)
      , events ""
      )

-------------------------------------------------------------------------------
-- File tests
-------------------------------------------------------------------------------

createFileWithParent :: FilePath -> FilePath -> IO ()
createFileWithParent file parent = do
    when (not (null file)) $
        createDirectoryIfMissing True (parent </> takeDirectory file)
    openFile (parent </> file) WriteMode >>= hClose

createFile :: FilePath -> FilePath -> IO ()
createFile file parent =
    openFile (parent </> file) WriteMode >>= hClose

fileCreate :: String -> (String -> [(String, Event -> Bool)]) -> TestDesc
fileCreate file1 events =
    ( "File created (" ++ file1 ++ ")"
    , createParent file1
    , createFile file1
    , (file1, fileEvent Event.isCreated) : events file1
    )

-- | See comments in dirCreateWithParent
fileCreateWithParent ::
    String -> (String -> [(String, Event -> Bool)]) -> TestDesc
fileCreateWithParent file1 events =
    ( "File created (" ++ file1 ++ ")"
    , const (return ())
    , createFileWithParent  file1
    , (file1, fileEvent Event.isCreated) : events file1
    )

fileDelete :: String -> (String -> [(String, Event -> Bool)]) -> TestDesc
fileDelete file1 events =
    ( "File deleted (" ++ file1 ++ ")"
    , createFileWithParent file1
    , \fp -> removeFile (fp </> file1)
    , events file1
    )

fileModify :: String -> (String -> [(String, Event -> Bool)]) -> TestDesc
fileModify file1 events =
    ( "File modified (" ++ file1 ++ ")"
    , createFileWithParent file1
    , \fp -> writeFile (fp </> file1) "Test Data"
    , (file1, fileEvent Event.isModified) : events file1
    )

fileMove ::
       String
    -> String
    -> (String -> String -> [(String, Event -> Bool)])
    -> TestDesc
fileMove file1 file2 events =
    ( "File moved (" ++ file1 ++ " " ++ file2 ++ ")"
    , createFileWithParent file1
    , \fp -> renamePath (fp </> file1) (fp </> file2)
    ,     (file1, fileEvent Event.isMoved)
        : (file2, fileEvent Event.isMoved)
        : events file1 file2
    )

rootFileMove :: String -> (String -> [(String, Event -> Bool)]) -> TestDesc
rootFileMove suffix events =
    ( "File moved (" ++ suffix ++ ")"
    , const (return ())
    , \fp -> renamePath fp (fp <> suffix)
    , events ""
    )

-------------------------------------------------------------------------------
-- Common test bundles
-------------------------------------------------------------------------------

withParent :: [Char] -> [Char] -> [Char]
withParent parent file = if null parent then file else parent </> file

testsWithParent :: String -> [TestDesc]
testsWithParent p =
    [ dirCreate
        (withParent p "dir") (\dir -> [(dir, dirEvent Event.isCreated)])
    , dirDelete
        (withParent p "dir") (\dir -> [(dir, dirEvent Event.isDeleted)])
    , dirMove
        (withParent p "dir1")
        (withParent p "dir2")
        (\dir1 dir2 ->
            [ (dir1, dirEvent Event.isMoved)
            , (dir2, dirEvent Event.isMoved)
            ]
        )
    , fileCreate (withParent p "file1") (const [])
    , fileDelete
        (withParent p "file1")
        (\file -> [(file, fileEvent Event.isDeleted)])
    , fileModify (withParent p "file1") (const [])
    , fileMove (withParent p "file1") (withParent p "file2") (\_ _ -> [])
    ]

commonTests :: [TestDesc]
commonTests = testsWithParent ""

commonRecTests :: [TestDesc]
commonRecTests = testsWithParent "subdir"

-------------------------------------------------------------------------------
-- Running tests
-------------------------------------------------------------------------------

runTests ::
       String
    -> String
    -> EventWatcher
    -> WatchRootType
    -> [TestDesc]
    -> IO ()
runTests modName watchType watcher rootType tests = do
    hSetBuffering stdout NoBuffering
    hspec
        $ describe modName
        $ describe watchType
        $ do
            let checker = checkEvents watcher
            describe ("Root type " ++ show rootType)
                    $ mapM_ (driver checker rootType) tests