pms-infra-watch-0.0.6.0: test/PMS/Infra/Watch/App/ControlSpec.hs
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE CPP #-}
module PMS.Infra.Watch.App.ControlSpec (spec) where
import Test.Hspec
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async
import Control.Exception (finally)
import qualified Control.Concurrent.STM as STM
import Control.Lens
import Data.Default
import Data.List (isPrefixOf)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.IO
import GHC.IO.Handle (hDuplicate, hDuplicateTo)
import System.IO.Temp (withSystemTempDirectory, withSystemTempFile)
import qualified Data.ByteString.Lazy as BL
import qualified PMS.Domain.Model.DM.Type as DM
import qualified PMS.Infra.Watch.App.Control as SUT
import qualified PMS.Infra.Watch.DM.Type as SUT
-- |
--
data SpecContext = SpecContext {
_domainDataSpecContext :: DM.DomainData
, _appDataSpecContext :: SUT.AppData
}
makeLenses ''SpecContext
defaultSpecContext :: IO SpecContext
defaultSpecContext = do
domDat <- DM.defaultDomainData
appDat <- SUT.defaultAppData
return SpecContext {
_domainDataSpecContext = domDat
, _appDataSpecContext = appDat
}
-- |
--
spec :: Spec
spec = do
runIO $ putStrLn "Start Spec."
beforeAll setUpOnce $
afterAll tearDownOnce .
beforeWith setUp .
after tearDown $ run
-- |
--
setUpOnce :: IO SpecContext
setUpOnce = do
putStrLn "[INFO] EXECUTED ONLY ONCE BEFORE ALL TESTS START."
defaultSpecContext
-- |
--
tearDownOnce :: SpecContext -> IO ()
tearDownOnce _ = do
putStrLn "[INFO] EXECUTED ONLY ONCE AFTER ALL TESTS FINISH."
-- |
--
setUp :: SpecContext -> IO SpecContext
setUp ctx = do
putStrLn "[INFO] EXECUTED BEFORE EACH TEST STARTS."
domDat <- DM.defaultDomainData
appDat <- SUT.defaultAppData
return ctx {
_domainDataSpecContext = domDat
, _appDataSpecContext = appDat
}
-- |
--
tearDown :: SpecContext -> IO ()
tearDown _ = do
putStrLn "[INFO] EXECUTED AFTER EACH TEST FINISHES."
-- |
--
run :: SpecWith SpecContext
run = do
describe "runWithAppData" $ do
context "when echo command issued." $ do
it "should call callback" $ \ctx -> do
putStrLn "[INFO] EXECUTING THE FIRST TEST."
let domDat = ctx^.domainDataSpecContext
appDat = ctx^.appDataSpecContext
cmdQ = domDat^.DM.watchQueueDomainData
notiQ = domDat^.DM.notificationQueueDomainData
expect = "abc"
argDat = DM.EchoWatchCommandData def expect
args = DM.EchoWatchCommand argDat
thId <- async $ SUT.runWithAppData appDat domDat
STM.atomically $ STM.writeTQueue cmdQ args
(DM.McpToolsListChangedNotification dat) <- STM.atomically $ STM.readTQueue notiQ
let actual = dat^.DM.methodMcpToolsListChangedNotificationData
actual `shouldBe` expect
cancel thId
-- TC-CR01: Verify that fsnotify internal log messages do NOT leak to stdout
-- when a PromptsListWatchCommand is issued.
-- On Windows, F.defaultConfig causes the WatchManager to write "fsnotify: h..."
-- to stdout, which corrupts the MCP JSON-RPC stream.
-- This test reproduces the defect and serves as a regression guard after the fix.
context "when PromptsListWatchCommand issued." $ do
it "should NOT leak fsnotify log messages to stdout" $ \ctx ->
withSystemTempDirectory "pms-watch-spec" $ \tmpDir -> do
putStrLn "[INFO] EXECUTING TC-CR01: fsnotify stdout leak test."
-- Prepare a temporary prompts directory with an empty prompts-list.json
let promptsDir = tmpDir </> "prompts"
promptsListFile = promptsDir </> "prompts-list.json"
createDirectoryIfMissing True promptsDir
writeFile promptsListFile "[]"
-- Override promptsDir in DomainData to point at the temp directory
let domDat0 = ctx^.domainDataSpecContext
domDat = domDat0 { DM._promptsDirDomainData = promptsDir }
appDat = ctx^.appDataSpecContext
cmdQ = domDat^.DM.watchQueueDomainData
-- Redirect stdout to a temp file and capture any fsnotify leakage
capturedLines <- withCapturedStdout $ do
thId <- async $ SUT.runWithAppData appDat domDat
-- Issue PromptsListWatchCommand to trigger watchTree startup
let argDat = DM.PromptsListWatchCommandData {}
args = DM.PromptsListWatchCommand argDat
STM.atomically $ STM.writeTQueue cmdQ args
-- Wait for watchTree to start and for fsnotify internals to settle
threadDelay (2 * 1000 * 1000)
cancel thId
hPutStrLn stderr $ "[INFO] TC-CR01: captured stdout lines: " ++ show capturedLines
-- Assert that no "fsnotify:" prefixed lines appeared on stdout
let leaked = filter ("fsnotify:" `isPrefixOf`) capturedLines
leaked `shouldBe` []
-- TC-CR05-01: Verify that notifications/tools/list_changed is successfully delivered
-- when tools-list.json (UTF-8) is updated by ToolsListWatchCommand.
-- BL.readFile prevents systemLocale decode exceptions from being thrown.
context "when ToolsListWatchCommand issued and tools-list.json updated." $ do
it "should deliver McpToolsListChangedNotification (TC-CR05-01)" $ \ctx ->
tcCR05Tools ctx
-- TC-CR05-02: Verify that notifications/prompts/list_changed is successfully delivered
-- when prompts-list.json (UTF-8) is updated by PromptsListWatchCommand.
context "when PromptsListWatchCommand issued and prompts-list.json updated." $ do
it "should deliver McpPromptsListChangedNotification (TC-CR05-02)" $ \ctx ->
tcCR05Prompts ctx
-- |
-- Redirect stdout to a temporary file for the duration of the action,
-- then return the captured output as a list of lines.
withCapturedStdout :: IO () -> IO [String]
withCapturedStdout action =
withSystemTempFile "captured-stdout" $ \tmpPath tmpHandle -> do
-- Save the original stdout handle
origStdout <- hDuplicate stdout
-- Redirect stdout to the temp file
hDuplicateTo tmpHandle stdout
hSetBuffering stdout LineBuffering
-- Run the action, then restore stdout
action `finally` do
hFlush stdout
hDuplicateTo origStdout stdout
hClose origStdout
hClose tmpHandle
-- Read back the captured content
content <- readFile tmpPath
return (lines content)
-- |
-- TC-CR05-01: Verify that notifications/tools/list_changed is successfully delivered
-- when tools-list.json (UTF-8) is updated by ToolsListWatchCommand.
-- Also verify that no fsnotify handler exceptions are output as noise (adopting BL.readFile
-- prevents systemLocale decode exceptions from being thrown).
tcCR05Tools :: SpecContext -> IO ()
tcCR05Tools ctx =
withSystemTempDirectory "pms-watch-cr05-tools" $ \tmpDir -> do
putStrLn "[INFO] EXECUTING TC-CR05-01: toolsList UTF-8 read test."
-- Prepare temp tools directory with a minimal tools-list.json (UTF-8)
let toolsDir = tmpDir </> "tools"
toolsListFile = toolsDir </> "tools-list.json"
toolsContent = "[{\"name\":\"pms_echo\",\"description\":\"echo\",\"inputSchema\":{\"type\":\"object\"}}]"
createDirectoryIfMissing True toolsDir
BL.writeFile toolsListFile (BL.pack (map (fromIntegral . fromEnum) toolsContent))
let domDat0 = ctx^.domainDataSpecContext
domDat = domDat0 { DM._toolsDirDomainData = toolsDir }
appDat = ctx^.appDataSpecContext
cmdQ = domDat^.DM.watchQueueDomainData
notiQ = domDat^.DM.notificationQueueDomainData
thId <- async $ SUT.runWithAppData appDat domDat
-- Issue ToolsListWatchCommand to register watchTree
let argDat = DM.ToolsListWatchCommandData {}
args = DM.ToolsListWatchCommand argDat
STM.atomically $ STM.writeTQueue cmdQ args
-- Wait for watchTree to start
threadDelay (1 * 1000 * 1000)
-- Overwrite tools-list.json to trigger the file-change event
let toolsContent2 = "[{\"name\":\"pms_echo2\",\"description\":\"echo2\",\"inputSchema\":{\"type\":\"object\"}}]"
BL.writeFile toolsListFile (BL.pack (map (fromIntegral . fromEnum) toolsContent2))
-- Wait for fsnotify event and notification delivery
threadDelay (2 * 1000 * 1000)
cancel thId
-- Assert that at least one McpToolsListChangedNotification was enqueued
notifications <- STM.atomically $ STM.flushTQueue notiQ
let toolsNoti = [ n | n@(DM.McpToolsListChangedNotification _) <- notifications ]
hPutStrLn stderr $ "[INFO] TC-CR05-01: tools notifications received: " ++ show (length toolsNoti)
length toolsNoti `shouldSatisfy` (>= 1)
-- |
-- TC-CR05-02: Verify that notifications/prompts/list_changed is successfully delivered
-- when prompts-list.json (UTF-8) is updated by PromptsListWatchCommand.
tcCR05Prompts :: SpecContext -> IO ()
tcCR05Prompts ctx =
withSystemTempDirectory "pms-watch-cr05-prompts" $ \tmpDir -> do
putStrLn "[INFO] EXECUTING TC-CR05-02: promptsList UTF-8 read test."
-- Prepare temp prompts directory with a minimal prompts-list.json (UTF-8)
let promptsDir = tmpDir </> "prompts"
promptsListFile = promptsDir </> "prompts-list.json"
promptsContent = "[{\"name\":\"skill_echo\",\"description\":\"echo skill\"}]"
createDirectoryIfMissing True promptsDir
BL.writeFile promptsListFile (BL.pack (map (fromIntegral . fromEnum) promptsContent))
let domDat0 = ctx^.domainDataSpecContext
domDat = domDat0 { DM._promptsDirDomainData = promptsDir }
appDat = ctx^.appDataSpecContext
cmdQ = domDat^.DM.watchQueueDomainData
notiQ = domDat^.DM.notificationQueueDomainData
thId <- async $ SUT.runWithAppData appDat domDat
-- Issue PromptsListWatchCommand to register watchTree
let argDat = DM.PromptsListWatchCommandData {}
args = DM.PromptsListWatchCommand argDat
STM.atomically $ STM.writeTQueue cmdQ args
-- Wait for watchTree to start
threadDelay (1 * 1000 * 1000)
-- Overwrite prompts-list.json to trigger the file-change event
let promptsContent2 = "[{\"name\":\"skill_echo2\",\"description\":\"echo2 skill\"}]"
BL.writeFile promptsListFile (BL.pack (map (fromIntegral . fromEnum) promptsContent2))
-- Wait for fsnotify event and notification delivery
threadDelay (2 * 1000 * 1000)
cancel thId
-- Assert that at least one McpPromptsListChangedNotification was enqueued
notifications <- STM.atomically $ STM.flushTQueue notiQ
let promptsNoti = [ n | n@(DM.McpPromptsListChangedNotification _) <- notifications ]
hPutStrLn stderr $ "[INFO] TC-CR05-02: prompts notifications received: " ++ show (length promptsNoti)
length promptsNoti `shouldSatisfy` (>= 1)