{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE PartialTypeSignatures #-} {-# LANGUAGE FlexibleContexts #-} module Main (main) where import Control.Concurrent.EQueue import Control.Concurrent.MVar import Control.Monad import Control.Monad.Trans import Control.Monad.Ref import qualified Control.Monad.Catch as E import qualified Control.Time as Time import qualified Data.ByteString as BS import qualified Data.ByteString.Builder as BB import qualified Data.ByteString.Lazy as BL import Data.Dependent.Sum (DSum ((:=>))) import Data.Fixed import Data.Functor.Identity import Data.Int import Data.List (sort, group) import qualified Data.ListTrie.Patricia.Map as LT import Data.Maybe import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Data.Time.Clock (getCurrentTime, diffUTCTime) import Foreign.Storable import Reflex import Reflex.Filesystem.DirTree import Reflex.Host.Class import System.Directory (removeDirectoryRecursive, createDirectory ,createDirectoryIfMissing, removeFile ,renameDirectory, setCurrentDirectory, getCurrentDirectory) import Reflex.Filesystem.Internal import System.FilePath (()) import qualified System.FilePath as FP import qualified System.FSNotify as FSN import System.IO.MMap import System.Posix.Temp (mkdtemp) import Test.Tasty import Test.Tasty.HUnit -- | Uses a watch on the specified directory and writing to a file in it -- to observe the local inotify latency. -- Then wait the specified multiple of that observed time. inotifyLatencyWait :: MonadIO m => Rational -> FilePath -> m () inotifyLatencyWait m f = liftIO $ do mvW <- newEmptyMVar st <- getCurrentTime let markFile = f "mark" FSN.withManager $ \wm -> do e <- FSN.watchDir wm f (const True) (const (void $ putMVar mvW ())) BS.writeFile markFile mempty void $ takeMVar mvW e removeFile markFile et <- getCurrentTime Time.delay (fromRational $ m*(toRational $ et `diffUTCTime` st)::Pico) -- | Writes a DirTree out to te filesystem. writeDirTree :: FilePath -> DirTree BS.ByteString -> IO () writeDirTree bDir dt = do createDirectoryIfMissing True bDir forM_ (LT.toList dt) $ \(p, c) -> do let subPath = FP.joinPath . fmap T.unpack $ p let fullPath = bDir subPath createDirectoryIfMissing True (FP.takeDirectory $ fullPath) BS.writeFile fullPath c bigExampleTree :: DirTree BS.ByteString bigExampleTree = LT.fromList [(["a"],"#") ,(["b","1"],"##5") ,(["b","2"],"##2") ,(["b","3"],"##3") ,(["c","d", "e"],"##4") ,(["o","n","e"], TE.encodeUtf8 "o n e") ,(["t","w","o"], TE.encodeUtf8 "t w o") ,(["t","w","2"], TE.encodeUtf8 "t w 2") ,(["one"], TE.encodeUtf8 "one") ,(["two"], TE.encodeUtf8 "two") ] main :: IO () main = defaultMain . testGroup "reflex-inotify" $ [ readAsUpdateTests , readTreeTests , e2eTests , watchDirTests , followDirTests , dirSourceTests -- Mostly to get coverage , testCase "DataUpdate Order" $ do assertBool "DataMod before PathDel" ((DataMod BS.empty) < PathDel) assertBool "DataMod before PathDel" ((DataMod BS.empty) <= PathDel) assertBool "DataMod before PathDel" (PathDel > (DataMod BS.empty)) assertBool "DataMod before PathDel" (PathDel >= (DataMod BS.empty)) assertEqual "DataMod LT PathDel" (compare (DataMod BS.empty) PathDel) LT -- Just for coverage , testCase "Show DataUpdate" $ do assertEqual "Show DataUpdate shows expected" (show (PathDel::DataUpdate BS.ByteString)) "PathDel" ] readAsUpdateTests :: TestTree readAsUpdateTests = testGroup "readAsUpdate" $ [ testCase "non-existant file" . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \tDir -> do r <- readAsUpdate tDir ("a") r' <- readAsUpdate tDir (tDir "a") r' @=? r [(["a"], PathDel)] @=? r , testCase "reads empty file" . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \tDir -> do let ef = tDir "empty" BS.writeFile ef mempty r <- readAsUpdate tDir ef [(["empty"], DataMod mempty)] @=? r , testCase "reads file contents" . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \tDir -> do let ef = tDir "full" let fconts = TE.encodeUtf8 "This is test file contents." BS.writeFile ef fconts r <- readAsUpdate tDir ef [(["full"], DataMod fconts)] @=? r ] readTreeTests :: TestTree readTreeTests = testGroup "readTree" $ (map (\(tName, bDir, sDir, t, dt) -> testCase tName . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \tDir -> do let wDir = tDir bDir writeDirTree wDir dt r <- readTree wDir sDir (sort . LT.toList . fmap DataMod . t $ dt) @=? (filter ((PathDel /=) . snd) $ sort r) ) $ [ ( "read an empty dir", "empty", "", id, mempty) , ( "read a dir with a file", "oneFile", "", id , LT.singleton ["one"] . TE.encodeUtf8 $ "one") , ( "read a file several dirs down", "deepFile", "", id , LT.singleton ["o","n","e"] . TE.encodeUtf8 $ "one") , ( "read several files", "multiFile", "", id, LT.fromList [ (["o","n","e"], TE.encodeUtf8 "o n e") , (["t","w","o"], TE.encodeUtf8 "t w o") , (["t","w","2"], TE.encodeUtf8 "t w 2") , (["one"], TE.encodeUtf8 "one") , (["two"], TE.encodeUtf8 "two") ] ) , ( "read an empty dir (deep)", "emptyS", "deeper", LT.lookupPrefix ["deeper"], mempty) , ( "read a dir with a file (deep)", "oneFileS", "deeper", LT.lookupPrefix ["deeper"] , LT.singleton ["deeper", "one"] . TE.encodeUtf8 $ "one") , ( "don't read a files (deep)", "deepFile", "deeper", LT.lookupPrefix ["deeper"] , LT.fromList [(["o","n","e"], TE.encodeUtf8 $ "one"), (["two"], TE.encodeUtf8 "two")]) , ( "read a file several dirs down (deep)", "deepFile", "deeper", LT.lookupPrefix ["deeper"] , LT.singleton ["deeper", "o","n","e"] . TE.encodeUtf8 $ "one") , ( "read several files (deep)", "multiFile", "deeper", LT.lookupPrefix ["deeper"], LT.fromList [ (["deeper", "o","n","e"], TE.encodeUtf8 "o n e") , (["deeper", "t","w","o"], TE.encodeUtf8 "t w o") , (["deeper", "t","w","2"], TE.encodeUtf8 "t w 2") , (["deeper", "one"], TE.encodeUtf8 "one") , (["deeper", "two"], TE.encodeUtf8 "two") ] ) ]) ++ [ testCase "read missing dir" . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \tDir -> do r <- readTree tDir "subdir" [(["subdir"], PathDel)] @=? r ] e2eTests :: TestTree e2eTests = testGroup "e2e" . map (\(tName, ev, er) -> testCase tName . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \tDir -> do let wDir = tDir tName writeDirTree wDir bigExampleTree ar <- e2e wDir ev er @=? (sort ar) ) $ [ ("Added existing file", FSN.Added "a" undefined undefined ,mkRes $ LT.lookupPrefix ["a"] bigExampleTree) , ("Added deep file", FSN.Added "b/1" undefined undefined ,mkRes $ LT.lookupPrefix ["b","1"] bigExampleTree) , ("Added existing dir", FSN.Added "b" undefined undefined ,mkRes $ LT.lookupPrefix ["b"] bigExampleTree) , ("Added fake file", FSN.Added "z" undefined undefined ,[(["z"], PathDel)]) , ("Modded existing file", FSN.Modified "a" undefined undefined ,mkRes $ LT.lookupPrefix ["a"] bigExampleTree) , ("Modded deep file", FSN.Modified "b/1" undefined undefined ,mkRes $ LT.lookupPrefix ["b","1"] bigExampleTree) , ("Modded fake file", FSN.Modified "z" undefined undefined ,[(["z"], PathDel)]) , ("Removed file", FSN.Removed "a" undefined undefined ,[(["a"], PathDel)]) , ("Removed dir", FSN.Removed "b" undefined undefined ,[(["b"], PathDel)]) , ("Removed fake", FSN.Removed "z" undefined undefined ,[(["z"], PathDel)]) , ("Unknown rescans", FSN.Unknown undefined undefined undefined ,mkRes bigExampleTree) ] where mkRes = sort . LT.toList . fmap DataMod watchDirTests :: TestTree watchDirTests = testGroup "watchDir" [ testWatch "No events on inaction" $ \_ -> [(return (), [])] , testWatch "memptyFile event" $ \wDir -> [ ( writeFile (wDir "memptyFile") "" , [(["memptyFile"], DataMod "")])] , testWatch "simpleFile with actual content" $ \wDir -> [ ( writeFile (wDir "simpleFile") "Nonempty" , [(["simpleFile"], DataMod "Nonempty")])] , testWatch "Empty directory creation has no event" $ \wDir -> [ (createDirectory (wDir "subDir"), [])] , testWatch "File creation in new subdir causes event" $ \wDir -> [ ( createDirectory (wDir "subDir") >> writeFile (wDir "subDir" "subFile") "#" , [(["subDir", "subFile"], DataMod "#")])] , testWatch "memptyFile deletion" $ \wDir -> [ ( writeFile (wDir "memptyFile") "" , [(["memptyFile"], DataMod "")]) , ( removeFile (wDir "memptyFile") , [(["memptyFile"], PathDel)]) ] , testWatch "Empty directory rename" $ \wDir -> [ ( createDirectory (wDir "subDir") >> renameDirectory (wDir "subDir") (wDir "otherDir") , [(["subDir"], PathDel)])] , testWatch "subFile removal" $ \wDir -> [ ( createDirectory (wDir "subDir") >> writeFile (wDir "subDir" "subFile") "#" , [(["subDir", "subFile"], DataMod "#")]) , ( removeFile (wDir "subDir" "subFile") , [(["subDir", "subFile"], PathDel)])] , testWatch "subDir move with file recreates" $ \wDir -> [ ( createDirectory (wDir "subDir"), []) , ( writeFile (wDir "subDir" "subFile") "#" , [(["subDir", "subFile"], DataMod "#")]) , ( renameDirectory (wDir "subDir") (wDir "otherDir") , [(["subDir"], PathDel) ,(["otherDir", "subFile"], DataMod "#")]) ] , testWatch "write into moved subDir" $ \wDir -> [ ( createDirectory (wDir "subDir"), []) , ( renameDirectory (wDir "subDir") (wDir "otherDir") , [(["subDir"], PathDel)]) , ( writeFile (wDir "otherDir" "subFile") "#" , [(["otherDir", "subFile"], DataMod "#")]) ] , testWatch "Fast write into moved subDir" $ \wDir -> [ ( createDirectory (wDir "subDir") >> renameDirectory (wDir "subDir") (wDir "otherDir") , [(["subDir"], PathDel)]) , ( writeFile (wDir "otherDir" "subFile") "#" , [(["otherDir", "subFile"], DataMod "#")]) ] , testWatch "mmap writes" $ \wDir -> [ ( mmapWithFilePtr (wDir "testFile") ReadWriteEx (Just (0, 8)) $ \(ptr, 8) -> pokeByteOff ptr 0 (0::Int64) , [(["testFile"], DataMod . BL.toStrict . BB.toLazyByteString . BB.int64BE $ 0)]) ] , testCase "Correct relative path watching" $ do -- There can only be one of these tests! -- Unless we have a global lock on what our working dir is. sDir <- getCurrentDirectory E.bracket (mkdtemp "/tmp/") (\d -> setCurrentDirectory sDir >> removeDirectoryRecursive d) $ \bDir -> do setCurrentDirectory bDir let wDir = "work" let iDir = bDir "watch" mapM_ createDirectory [wDir, iDir] let cases = [ ( writeFile (wDir "memptyFile") "" , [(["memptyFile"], DataMod "")]) , ( removeFile (wDir "memptyFile") , [(["memptyFile"], PathDel)]) , ( writeFile (wDir "simpleFile") "Nonempty" , [(["simpleFile"], DataMod "Nonempty")]) , (createDirectory (wDir "subDir"), []) , (writeFile (wDir "subDir" "subFile") "#" , [(["subDir", "subFile"], DataMod "#")]) , ( removeFile (wDir "subDir" "subFile") , [(["subDir", "subFile"], PathDel)]) , (renameDirectory (wDir "subDir") (wDir "otherDir") , [(["subDir"], PathDel)]) , ( writeFile (wDir "otherDir" "subFile") "#" , [(["otherDir", "subFile"], DataMod "#")]) -- ^ !!! This triggers a bug in fsnotify where renaming directories -- fails to update the path to the watch. , ( renameDirectory (wDir "otherDir") (wDir "subDir") , [(["otherDir"], PathDel) ,(["subDir", "subFile"], DataMod "#")]) ] runSpiderHost $ do eq <- newSTMEQueue fsE <- (runHostFrame $ watchDir eq wDir) >>= subscribeEvent let doStep act = do -- Trigger the causal action. void $ liftIO act -- we wait for the event to show up. inotifyLatencyWait 2 iDir es <- waitEQ eq ReturnImmediate (sort . map head . group . fromMaybe []) <$> (fireEventsAndRead es (readEvent fsE >>= sequence)) forM_ (cases++[(return (), [])]) $ \(ioAct, e) -> doStep ioAct >>= (liftIO . ((sort e) @=?)) fireEventsAndRead [] . void $ readEvent fsE ] where testWatch :: String -> (String -> [(IO (), [([T.Text], DataUpdate BS.ByteString)])]) -> TestTree testWatch testName eventCreator = testCase testName . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \bDir -> do let wDir = bDir "work" let iDir = bDir "watch" mapM_ createDirectory [wDir, iDir] runSpiderHost $ do eq <- newSTMEQueue fsE <- (runHostFrame $ watchDir eq wDir) >>= subscribeEvent let doStep act = do -- Trigger the causal action. void $ liftIO act -- we wait for the event to show up. inotifyLatencyWait 2 iDir es <- waitEQ eq ReturnImmediate (sort . map head . group . fromMaybe []) <$> (fireEventsAndRead es (readEvent fsE >>= sequence)) forM_ ((eventCreator wDir)++[(return (), [])]) $ \(ioAct, e) -> doStep ioAct >>= (liftIO . ((sort e) @=?)) fireEventsAndRead [] . void $ readEvent fsE decodeDynDirTree :: (Reflex t, MonadSample t m) => DynDirTree t a -> m (DirTree a) decodeDynDirTree dt = sample (current dt) >>= mapM (sample . current) followDirTests :: TestTree followDirTests = testGroup "followDir" [ testFDir "No events starts correct (mempty)" [] [(Nothing, mempty)] , let baseData = [(["a"], "#"), (["b", "c"], "##")] in testFDir "No events starts correct (nontrivial)" (map (fmap DataMod) baseData) [(Nothing, baseData)] , testGroup "Single Update" [ testFDir "Addative" [] [ (jS (["a"],DataMod "#"), [(["a"],"#")]) , (jS (["b","c"],DataMod "##"), [(["a"],"#"),(["b","c"],"##")]) ] , testFDir "Replace" [ (["a"],DataMod "#"),(["b","c"],DataMod "##") ] [ (jS (["a"], DataMod "1"), [ (["a"],"1") , (["b","c"],"##") ]) , (jS (["b"], DataMod "2"), [ (["a"],"1") , (["b"],"2") , (["b","c"],"##") ]) , (jS (["b", "c"], DataMod "3"), [ (["a"],"1") , (["b"],"2") , (["b","c"],"3") ]) ] , testFDir "Subtractive" [ (["a"],DataMod "#"),(["b","c"],DataMod "##") ] [ (jS (["a"],PathDel), [(["b","c"],"##")]) , (jS (["b","c"],PathDel), mempty) ] , testFDir "Path deletion" [(["a"],DataMod "#") ,(["b","1"],DataMod "##1") ,(["b","2"],DataMod "##2") ,(["b","3"],DataMod "##3") ] [ (jS (["b"], PathDel), [(["a"],"#")]) ] , testFDir "Mutative" [(["a"],DataMod "#") ,(["b","1"],DataMod "##1") ,(["b","2"],DataMod "##2") ,(["b","3"],DataMod "##3") ] [ (Nothing, [(["a"],"#") ,(["b","1"],"##1") ,(["b","2"],"##2") ,(["b","3"],"##3")]) , (jS (["c","d", "e"], DataMod "##4"), [(["a"],"#") ,(["b","1"],"##1") ,(["b","2"],"##2") ,(["b","3"],"##3") ,(["c","d", "e"],"##4")]) , (jS (["b", "1"], PathDel), [(["a"],"#") ,(["b","2"],"##2") ,(["b","3"],"##3") ,(["c","d", "e"],"##4")]) , (jS (["b", "1"], DataMod "##5"), [(["a"],"#") ,(["b","1"],"##5") ,(["b","2"],"##2") ,(["b","3"],"##3") ,(["c","d", "e"],"##4")]) , (jS (["b"], PathDel), [(["a"],"#"), (["c","d", "e"],"##4")]) ] ] , testGroup "Multi Update" [ testFDir "Addative" [] [ (jFL [(["a"],DataMod "#"), (["b","c"],DataMod "##")] ,[(["a"],"#"),(["b","c"],"##")]) ] , testFDir "Replace" [ (["a"],DataMod "#"),(["b","c"],DataMod "##") ] [ (jFL [ (["a"], DataMod "1") , (["b"], DataMod "2") , (["b", "c"], DataMod "3") ], [ (["a"],"1") , (["b"],"2") , (["b","c"],"3") ]) ] , testFDir "Subtractive" [ (["a"],DataMod "#"),(["b","c"],DataMod "##") ] [ (jFL [ (["a"],PathDel) , (["b","c"],PathDel) ] , mempty) ] , testFDir "Mutative" [(["a"],DataMod "#") ,(["b","1"],DataMod "##1") ,(["b","2"],DataMod "##2") ,(["b","3"],DataMod "##3") ] [ (jFL [ (["c","d", "e"], DataMod "##4") , (["b", "1"], PathDel) , (["b", "1"], DataMod "##5") , (["b"], PathDel) ] , [(["a"],"#"), (["c","d", "e"],"##4")]) ] ] ] where jS :: ([T.Text], DataUpdate BS.ByteString) -> Maybe [([T.Text], DataUpdate BS.ByteString)] jS = Just . pure jFL :: [([T.Text], DataUpdate BS.ByteString)] -> Maybe [([T.Text], DataUpdate BS.ByteString)] jFL = Just testFDir :: String -> [([T.Text], DataUpdate BS.ByteString)] -> [(Maybe [([T.Text], DataUpdate BS.ByteString)], [([T.Text], BS.ByteString)])] -> TestTree testFDir nm initial updates = testCase nm $ do runSpiderHost $ do (re, rmt) <- newEventWithTriggerRef rd <- runHostFrame $ followDir initial re mrt <- readRef rmt rt <- maybe (liftIO $ assertFailure "event not used") return mrt let doStep ev = do -- Trigger the causal action. fromMaybe (return ()) . fmap (\v -> fireEventsAndRead [rt :=> (Identity v)] (return ())) $ ev decodeDynDirTree rd forM_ updates $ \(ev, r) -> doStep ev >>= (liftIO . ((LT.fromList r) @=?)) dirSourceTests :: TestTree dirSourceTests = testGroup "dirSource" [ testWDir "No actions remains empty" mempty $ \_ -> [(return (), mempty)] , testWDir "No actions doesn't change" (LT.singleton ["a"] (TE.encodeUtf8 "Test")) $ \_ -> [(return (), LT.singleton ["a"] (TE.encodeUtf8 "Test"))] , testWDir "Fills DirTree" mempty $ \wDir -> [(writeDirTree wDir bigExampleTree, bigExampleTree)] , testWDir "Reads full tree" bigExampleTree $ \_ -> [(return (), bigExampleTree)] , testCase "Initially samples correct" . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \bDir -> do let wDir = bDir "work" let iDir = bDir "watch" mapM_ createDirectory [wDir, iDir] writeDirTree wDir bigExampleTree runSpiderHost $ do eq <- newSTMEQueue rd <- (runHostFrame $ dirSource eq wDir) (liftIO . (bigExampleTree @=?)) =<< decodeDynDirTree rd ] where testWDir :: String -> DirTree BS.ByteString -> (String -> [(IO (), DirTree BS.ByteString)]) -> TestTree testWDir nm initial eventCreator = testCase nm . E.bracket (mkdtemp "/tmp/") (removeDirectoryRecursive) $ \bDir -> do let wDir = bDir "work" let iDir = bDir "watch" mapM_ createDirectory [wDir, iDir] writeDirTree wDir initial runSpiderHost $ do eq <- newSTMEQueue rd <- (runHostFrame $ dirSource eq wDir) let doStep act = do -- Trigger the causal action. void $ liftIO act -- we wait for the event to show up. inotifyLatencyWait 2 iDir es <- waitEQ eq ReturnImmediate fireEventsAndRead es $ return () decodeDynDirTree rd forM_ (eventCreator wDir) $ \(ioAct, e) -> doStep ioAct >>= (liftIO . (e @=?))