tricorder 0.4.1.1 → 0.5.0.0
raw patch · 74 files changed
+4540/−3101 lines, 74 filesdep +tasty-hunitdep −hspecdep −tasty-hspecdep ~basedep ~brickPVP ok
version bump matches the API change (PVP)
Dependencies added: tasty-hunit
Dependencies removed: hspec, tasty-hspec
Dependency ranges changed: base, brick
API changes (from Hackage documentation)
- Tricorder.Build: [daemonInfo] :: BuildState -> DaemonInfo
- Tricorder.CLI.Arguments: JsonOutput :: OutputFormat
- Tricorder.CLI.Arguments: TextOutput :: OutputFormat
- Tricorder.CLI.Arguments: data OutputFormat
- Tricorder.CLI.UI.Route: DaemonInfo :: Route
- Tricorder.Session: [command] :: Session -> Command
- Tricorder.Session: [targets] :: Session -> [Target]
- Tricorder.Session: [testTargets] :: Session -> [TestTarget]
- Tricorder.Session.CabalFile: discoverCabalFiles :: forall (es :: [Effect]). (Env :> es, FileSystem :> es, Glob :> es, Reader ProjectRoot :> es) => Eff es [FilePath]
- Tricorder.Session.Command: Cabal :: Repl
- Tricorder.Session.Command: Command :: Repl -> [Text] -> [Target] -> Command
- Tricorder.Session.Command: Stack :: Repl
- Tricorder.Session.Command: StackMulti :: Repl
- Tricorder.Session.Command: Unknown :: Repl
- Tricorder.Session.Command: [arguments] :: Command -> [Text]
- Tricorder.Session.Command: [repl] :: Command -> Repl
- Tricorder.Session.Command: [targets] :: Command -> [Target]
- Tricorder.Session.Command: data Command
- Tricorder.Session.Command: data Repl
- Tricorder.Session.Command: instance Data.Default.Internal.Default Tricorder.Session.Command.Command
- Tricorder.Session.Command: instance GHC.Classes.Eq Tricorder.Session.Command.Command
- Tricorder.Session.Command: instance GHC.Classes.Eq Tricorder.Session.Command.Repl
- Tricorder.Session.Command: instance GHC.Generics.Generic Tricorder.Session.Command.Command
- Tricorder.Session.Command: instance GHC.Generics.Generic Tricorder.Session.Command.Repl
- Tricorder.Session.Command: instance GHC.Show.Show Tricorder.Session.Command.Command
- Tricorder.Session.Command: instance GHC.Show.Show Tricorder.Session.Command.Repl
- Tricorder.Session.Command: render :: Command -> Text
- Tricorder.Session.Command: resolveCommand :: forall (es :: [Effect]). FileSystem :> es => ProjectRoot -> Config -> [Target] -> [TestTarget] -> Eff es Command
- Tricorder.Session.Target: compareTargets :: (Target -> Bool) -> Target -> Target -> Ordering
- Tricorder.Session.Target: parseTarget :: Text -> Target
- Tricorder.Session.Target: renderTarget :: Target -> Text
- Tricorder.Session.Target: resolveTargets :: [CabalFile] -> [Text] -> [Target]
- Tricorder.Session.TestTarget: parseTestTargets :: [Text] -> [TestTarget]
- Tricorder.Session.TestTarget: projectTestTargets :: [Target] -> [TestTarget]
- Tricorder.Session.TestTarget: renderTestTarget :: TestTarget -> Text
- Tricorder.Session.TestTarget: resolveTestTargets :: Config -> [Target] -> [TestTarget]
- Tricorder.Session.WatchDirs: resolveWatchDirs :: ProjectRoot -> [CabalFile] -> Config -> [Target] -> WatchDirs
+ Tricorder.CLI.Arguments: Daemon :: DaemonCommand -> Command
+ Tricorder.CLI.Arguments.Daemon: parser :: Parser DaemonCommand
+ Tricorder.CLI.Arguments.OutputFormat: parser :: Parser OutputFormat
+ Tricorder.CLI.Operations: showDaemonInfo :: forall (es :: [Effect]). Console :> es => OutputFormat -> DaemonInfo -> Eff es ()
+ Tricorder.Daemon.GhciSession.GhciProcess: MkUnexpectedExit :: Text -> Text -> UnexpectedExit
+ Tricorder.Daemon.GhciSession.GhciProcess: [errorMessage] :: UnexpectedExit -> Text
+ Tricorder.Daemon.GhciSession.GhciProcess: [marker] :: UnexpectedExit -> Text
+ Tricorder.Daemon.GhciSession.GhciProcess: data UnexpectedExit
+ Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciProcess.UnexpectedExit
+ Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciProcess.UnexpectedExit
+ Tricorder.Session: [buildSession] :: Session -> BuildSession
+ Tricorder.Session: [evalSession] :: Session -> CommandTemplate 'Eval
+ Tricorder.Session: [testSession] :: Session -> TestSession
+ Tricorder.Session: show :: Session -> Text
+ Tricorder.Session.CabalFile: discoverCabalPackages :: forall (es :: [Effect]). (Env :> es, FileSystem :> es, Glob :> es, Reader ProjectRoot :> es) => Eff es (Either Text [FilePath])
+ Tricorder.Session.CabalFile: discoverPackages :: forall (es :: [Effect]). (Env :> es, FileSystem :> es, Glob :> es, Input StackProject :> es, Reader ProjectRoot :> es) => Eff es (Either Text [FilePath])
+ Tricorder.Session.CabalFile: discoverStackPackages :: forall (es :: [Effect]). (Input StackProject :> es, Reader ProjectRoot :> es) => Eff es (Either Text [FilePath])
+ Tricorder.Session.CabalFile: readProjectFile :: forall (es :: [Effect]). FileSystem :> es => FilePath -> Eff es (Either FilePath CabalFile)
+ Tricorder.Session.Command.RenderedCommand: RenderedCommand :: Text -> RenderedCommand (stage :: Stage)
+ Tricorder.Session.Command.RenderedCommand: [getRenderedCommand] :: RenderedCommand (stage :: Stage) -> Text
+ Tricorder.Session.Command.RenderedCommand: instance GHC.Classes.Eq (Tricorder.Session.Command.RenderedCommand.RenderedCommand stage)
+ Tricorder.Session.Command.RenderedCommand: instance GHC.Show.Show (Tricorder.Session.Command.RenderedCommand.RenderedCommand stage)
+ Tricorder.Session.Command.RenderedCommand: newtype RenderedCommand (stage :: Stage)
+ Tricorder.Session.CommandConfig: CommandConfig :: Maybe Text -> Maybe [Text] -> [Text] -> CommandConfig (stage :: Stage)
+ Tricorder.Session.CommandConfig: [commandTemplate] :: CommandConfig (stage :: Stage) -> Maybe Text
+ Tricorder.Session.CommandConfig: [extraAutoArguments] :: CommandConfig (stage :: Stage) -> [Text]
+ Tricorder.Session.CommandConfig: [targets] :: CommandConfig (stage :: Stage) -> Maybe [Text]
+ Tricorder.Session.CommandConfig: data CommandConfig (stage :: Stage)
+ Tricorder.Session.CommandConfig: instance Data.Aeson.Types.FromJSON.FromJSON (Tricorder.Session.CommandConfig.CommandConfig stage)
+ Tricorder.Session.CommandConfig: instance Data.Aeson.Types.ToJSON.ToJSON (Tricorder.Session.CommandConfig.CommandConfig stage)
+ Tricorder.Session.CommandConfig: instance Data.Default.Internal.Default (Tricorder.Session.CommandConfig.CommandConfig stage)
+ Tricorder.Session.CommandConfig: instance GHC.Classes.Eq (Tricorder.Session.CommandConfig.CommandConfig stage)
+ Tricorder.Session.CommandConfig: instance GHC.Generics.Generic (Tricorder.Session.CommandConfig.CommandConfig stage)
+ Tricorder.Session.CommandConfig: instance GHC.Show.Show (Tricorder.Session.CommandConfig.CommandConfig stage)
+ Tricorder.Session.CommandTemplate: CommandTemplate :: Repl -> Text -> [Text] -> Text -> CommandTemplate (stage :: Stage)
+ Tricorder.Session.CommandTemplate: [arguments] :: CommandTemplate (stage :: Stage) -> [Text]
+ Tricorder.Session.CommandTemplate: [placeholder] :: CommandTemplate (stage :: Stage) -> Text
+ Tricorder.Session.CommandTemplate: [repl] :: CommandTemplate (stage :: Stage) -> Repl
+ Tricorder.Session.CommandTemplate: [template] :: CommandTemplate (stage :: Stage) -> Text
+ Tricorder.Session.CommandTemplate: data CommandTemplate (stage :: Stage)
+ Tricorder.Session.CommandTemplate: hasPlaceholder :: Text -> Text -> Bool
+ Tricorder.Session.CommandTemplate: instance Data.Default.Internal.Default (Tricorder.Session.CommandTemplate.CommandTemplate 'Tricorder.Session.Stage.Build)
+ Tricorder.Session.CommandTemplate: instance Data.Default.Internal.Default (Tricorder.Session.CommandTemplate.CommandTemplate 'Tricorder.Session.Stage.Eval)
+ Tricorder.Session.CommandTemplate: instance Data.Default.Internal.Default (Tricorder.Session.CommandTemplate.CommandTemplate 'Tricorder.Session.Stage.Test)
+ Tricorder.Session.CommandTemplate: instance GHC.Classes.Eq (Tricorder.Session.CommandTemplate.CommandTemplate stage)
+ Tricorder.Session.CommandTemplate: instance GHC.Generics.Generic (Tricorder.Session.CommandTemplate.CommandTemplate stage)
+ Tricorder.Session.CommandTemplate: instance GHC.Show.Show (Tricorder.Session.CommandTemplate.CommandTemplate stage)
+ Tricorder.Session.CommandTemplate: renderTargetsFor :: Repl -> [Target] -> [Text]
+ Tricorder.Session.CommandTemplate: renderText :: forall (stage :: Stage). CommandTemplate stage -> [Target] -> Text
+ Tricorder.Session.CommandTemplate: show :: forall (stage :: Stage). CommandTemplate stage -> Text
+ Tricorder.Session.CommandTemplate: targetPlaceholder :: Text
+ Tricorder.Session.CommandTemplate: targetsPlaceholder :: Text
+ Tricorder.Session.Config: [build] :: Config -> CommandConfig 'Build
+ Tricorder.Session.Config: [eval] :: Config -> CommandConfig 'Eval
+ Tricorder.Session.Config: [test] :: Config -> TestConfig
+ Tricorder.Session.Repl: Cabal :: Repl
+ Tricorder.Session.Repl: Stack :: Repl
+ Tricorder.Session.Repl: StackMulti :: Repl
+ Tricorder.Session.Repl: Unknown :: Repl
+ Tricorder.Session.Repl: data Repl
+ Tricorder.Session.Repl: instance GHC.Classes.Eq Tricorder.Session.Repl.Repl
+ Tricorder.Session.Repl: instance GHC.Generics.Generic Tricorder.Session.Repl.Repl
+ Tricorder.Session.Repl: instance GHC.Show.Show Tricorder.Session.Repl.Repl
+ Tricorder.Session.Repl: resolveRepl :: forall (es :: [Effect]). FileSystem :> es => ProjectRoot -> Eff es Repl
+ Tricorder.Session.StackProject: StackProject :: [FilePath] -> StackProject
+ Tricorder.Session.StackProject: [packages] :: StackProject -> [FilePath]
+ Tricorder.Session.StackProject: inputWithCache :: forall (es :: [Effect]) a. (Concurrent :> es, FileSystem :> es, Reader ProjectRoot :> es) => Eff (Input StackProject ': es) a -> Eff es a
+ Tricorder.Session.StackProject: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.StackProject.StackProject
+ Tricorder.Session.StackProject: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.StackProject.StackProject
+ Tricorder.Session.StackProject: instance GHC.Classes.Eq Tricorder.Session.StackProject.StackProject
+ Tricorder.Session.StackProject: instance GHC.Generics.Generic Tricorder.Session.StackProject.StackProject
+ Tricorder.Session.StackProject: instance GHC.Show.Show Tricorder.Session.StackProject.StackProject
+ Tricorder.Session.StackProject: newtype StackProject
+ Tricorder.Session.Stage: Build :: Stage
+ Tricorder.Session.Stage: Eval :: Stage
+ Tricorder.Session.Stage: Test :: Stage
+ Tricorder.Session.Stage: data Stage
+ Tricorder.Session.Stage.Build.Command: render :: CommandTemplate 'Build -> [Target] -> RenderedCommand 'Build
+ Tricorder.Session.Stage.Build.Command: resolve :: forall (es :: [Effect]). FileSystem :> es => ProjectRoot -> Config -> Repl -> [Target] -> Eff es (CommandTemplate 'Build, [Target])
+ Tricorder.Session.Stage.Build.Session: BuildSession :: CommandTemplate 'Build -> [Target] -> BuildSession
+ Tricorder.Session.Stage.Build.Session: [commandTemplate] :: BuildSession -> CommandTemplate 'Build
+ Tricorder.Session.Stage.Build.Session: [targets] :: BuildSession -> [Target]
+ Tricorder.Session.Stage.Build.Session: data BuildSession
+ Tricorder.Session.Stage.Build.Session: instance Data.Default.Internal.Default Tricorder.Session.Stage.Build.Session.BuildSession
+ Tricorder.Session.Stage.Build.Session: instance GHC.Classes.Eq Tricorder.Session.Stage.Build.Session.BuildSession
+ Tricorder.Session.Stage.Build.Session: resolve :: forall (es :: [Effect]). (FileSystem :> es, Reader ProjectRoot :> es) => Config -> Repl -> [CabalFile] -> Eff es BuildSession
+ Tricorder.Session.Stage.Build.Session: show :: BuildSession -> Text
+ Tricorder.Session.Stage.Eval.Command: render :: CommandTemplate 'Eval -> [Target] -> RenderedCommand 'Eval
+ Tricorder.Session.Stage.Eval.Command: resolve :: Repl -> Config -> CommandTemplate 'Eval
+ Tricorder.Session.Stage.Eval.Session: show :: EvalSession -> Text
+ Tricorder.Session.Stage.Eval.Session: type EvalSession = CommandTemplate 'Eval
+ Tricorder.Session.Stage.Test.Command: RenderedTestCommand :: RenderedCommand 'Test -> ResolvedTestOptions -> RenderedTestCommand
+ Tricorder.Session.Stage.Test.Command: [command] :: RenderedTestCommand -> RenderedCommand 'Test
+ Tricorder.Session.Stage.Test.Command: [options] :: RenderedTestCommand -> ResolvedTestOptions
+ Tricorder.Session.Stage.Test.Command: data RenderedTestCommand
+ Tricorder.Session.Stage.Test.Command: render :: TestSession -> Maybe ByteSize -> TestTarget -> RenderedTestCommand
+ Tricorder.Session.Stage.Test.Config: Options :: Maybe OutputMode -> Options
+ Tricorder.Session.Stage.Test.Config: ReplOutput :: OutputMode
+ Tricorder.Session.Stage.Test.Config: StdoutOutput :: OutputMode
+ Tricorder.Session.Stage.Test.Config: TestConfig :: CommandConfig 'Test -> Options -> TestConfig
+ Tricorder.Session.Stage.Test.Config: [commandConfig] :: TestConfig -> CommandConfig 'Test
+ Tricorder.Session.Stage.Test.Config: [options] :: TestConfig -> Options
+ Tricorder.Session.Stage.Test.Config: [outputMode] :: Options -> Maybe OutputMode
+ Tricorder.Session.Stage.Test.Config: data OutputMode
+ Tricorder.Session.Stage.Test.Config: data TestConfig
+ Tricorder.Session.Stage.Test.Config: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.Stage.Test.Config.Options
+ Tricorder.Session.Stage.Test.Config: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.Stage.Test.Config.OutputMode
+ Tricorder.Session.Stage.Test.Config: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.Stage.Test.Config.TestConfig
+ Tricorder.Session.Stage.Test.Config: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Stage.Test.Config.Options
+ Tricorder.Session.Stage.Test.Config: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Stage.Test.Config.OutputMode
+ Tricorder.Session.Stage.Test.Config: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Stage.Test.Config.TestConfig
+ Tricorder.Session.Stage.Test.Config: instance Data.Default.Internal.Default Tricorder.Session.Stage.Test.Config.Options
+ Tricorder.Session.Stage.Test.Config: instance Data.Default.Internal.Default Tricorder.Session.Stage.Test.Config.TestConfig
+ Tricorder.Session.Stage.Test.Config: instance GHC.Classes.Eq Tricorder.Session.Stage.Test.Config.Options
+ Tricorder.Session.Stage.Test.Config: instance GHC.Classes.Eq Tricorder.Session.Stage.Test.Config.OutputMode
+ Tricorder.Session.Stage.Test.Config: instance GHC.Classes.Eq Tricorder.Session.Stage.Test.Config.TestConfig
+ Tricorder.Session.Stage.Test.Config: instance GHC.Generics.Generic Tricorder.Session.Stage.Test.Config.Options
+ Tricorder.Session.Stage.Test.Config: instance GHC.Generics.Generic Tricorder.Session.Stage.Test.Config.OutputMode
+ Tricorder.Session.Stage.Test.Config: instance GHC.Generics.Generic Tricorder.Session.Stage.Test.Config.TestConfig
+ Tricorder.Session.Stage.Test.Config: instance GHC.Show.Show Tricorder.Session.Stage.Test.Config.Options
+ Tricorder.Session.Stage.Test.Config: instance GHC.Show.Show Tricorder.Session.Stage.Test.Config.OutputMode
+ Tricorder.Session.Stage.Test.Config: instance GHC.Show.Show Tricorder.Session.Stage.Test.Config.TestConfig
+ Tricorder.Session.Stage.Test.Config: newtype Options
+ Tricorder.Session.Stage.Test.Session: ResolvedTestOptions :: OutputMode -> ResolvedTestOptions
+ Tricorder.Session.Stage.Test.Session: TestSession :: CommandTemplate 'Test -> [TestTarget] -> ResolvedTestOptions -> TestSession
+ Tricorder.Session.Stage.Test.Session: [commandTemplate] :: TestSession -> CommandTemplate 'Test
+ Tricorder.Session.Stage.Test.Session: [options] :: TestSession -> ResolvedTestOptions
+ Tricorder.Session.Stage.Test.Session: [outputMode] :: ResolvedTestOptions -> OutputMode
+ Tricorder.Session.Stage.Test.Session: [targets] :: TestSession -> [TestTarget]
+ Tricorder.Session.Stage.Test.Session: data ResolvedTestOptions
+ Tricorder.Session.Stage.Test.Session: data TestSession
+ Tricorder.Session.Stage.Test.Session: defaultTestTemplate :: Repl -> Text
+ Tricorder.Session.Stage.Test.Session: instance Data.Default.Internal.Default Tricorder.Session.Stage.Test.Session.ResolvedTestOptions
+ Tricorder.Session.Stage.Test.Session: instance Data.Default.Internal.Default Tricorder.Session.Stage.Test.Session.TestSession
+ Tricorder.Session.Stage.Test.Session: instance GHC.Classes.Eq Tricorder.Session.Stage.Test.Session.ResolvedTestOptions
+ Tricorder.Session.Stage.Test.Session: instance GHC.Classes.Eq Tricorder.Session.Stage.Test.Session.TestSession
+ Tricorder.Session.Stage.Test.Session: resolve :: Repl -> [Target] -> Config -> TestSession
+ Tricorder.Session.Stage.Test.Session: show :: TestSession -> Text
+ Tricorder.Session.Target: compare :: (Target -> Bool) -> Target -> Target -> Ordering
+ Tricorder.Session.Target: parse :: Text -> Target
+ Tricorder.Session.Target: render :: Target -> Text
+ Tricorder.Session.Target: resolve :: [CabalFile] -> [Text] -> [Target]
+ Tricorder.Session.TestTarget: parse :: [Text] -> [TestTarget]
+ Tricorder.Session.TestTarget: project :: [Target] -> [TestTarget]
+ Tricorder.Session.TestTarget: render :: TestTarget -> Text
+ Tricorder.Session.TestTarget: resolve :: Config -> [Target] -> [TestTarget]
+ Tricorder.Session.Util: indent :: Text -> Text
+ Tricorder.Session.Util: showList :: (a -> Text) -> [a] -> Text
+ Tricorder.Session.WatchDirs: resolve :: ProjectRoot -> [CabalFile] -> Config -> [Target] -> WatchDirs
- Tricorder.Build: BuildState :: DaemonInfo -> BuildPhase -> BuildId -> BuildState
+ Tricorder.Build: BuildState :: BuildPhase -> BuildId -> BuildState
- Tricorder.CLI.App: run :: forall (es :: [Effect]). (Brick :> es, BrickChan :> es, Cache (PackageId, SourceQuery) ModuleSourceResult :> es, Cache ModuleName PackageId :> es, Clock :> es, Conc :> es, Concurrent :> es, Console :> es, Daemons :> es, Delay :> es, Exit :> es, File :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, IOE :> es, Input Repl :> es, Log :> es, PackageStore :> es, Process :> es, Reader Command :> es, Reader Config :> es, Reader LogPath :> es, Reader PidFile :> es, Reader SocketPath :> es, Timeout :> es, UnixSocket :> es) => Eff es ()
+ Tricorder.CLI.App: run :: forall (es :: [Effect]). (Brick :> es, BrickChan :> es, Cache (PackageId, SourceQuery) ModuleSourceResult :> es, Cache ModuleName PackageId :> es, Clock :> es, Conc :> es, Concurrent :> es, Console :> es, Daemons :> es, Delay :> es, Exit :> es, File :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, IOE :> es, Input DaemonInfo :> es, Input Repl :> es, Log :> es, PackageStore :> es, Process :> es, Reader Command :> es, Reader Config :> es, Reader LogPath :> es, Reader PidFile :> es, Reader SocketPath :> es, Timeout :> es, UnixSocket :> es) => Eff es ()
- Tricorder.Daemon.Builder: with :: forall (es :: [Effect]) a. (Clock :> es, GhciSession :> es, Log :> es, Pub BuildProgress :> es, Reader ProjectRoot :> es) => BuildId -> Command -> WatchDirs -> (BuilderState -> NewLoadResult -> Eff (Builder ': es) a) -> Eff es (Either SomeException a)
+ Tricorder.Daemon.Builder: with :: forall (es :: [Effect]) a. (Clock :> es, GhciSession :> es, Log :> es, Pub BuildProgress :> es, Reader ProjectRoot :> es) => BuildId -> RenderedCommand 'Build -> WatchDirs -> (BuilderState -> NewLoadResult -> Eff (Builder ': es) a) -> Eff es (Either SomeException a)
- Tricorder.Daemon.EvalCommentRunner: [EvaluateComments] :: forall (a :: Type -> Type). Repl -> NonEmpty (LoadedModule, NonEmpty Comment) -> EvalCommentRunner a (NonEmpty Evaluation)
+ Tricorder.Daemon.EvalCommentRunner: [EvaluateComments] :: forall (a :: Type -> Type). CommandTemplate 'Eval -> NonEmpty (LoadedModule, NonEmpty Comment) -> EvalCommentRunner a (NonEmpty Evaluation)
- Tricorder.Daemon.EvalCommentRunner: evaluateComments :: forall (es :: [Effect]). (HasCallStack, EvalCommentRunner :> es) => Repl -> NonEmpty (LoadedModule, NonEmpty Comment) -> Eff es (NonEmpty Evaluation)
+ Tricorder.Daemon.EvalCommentRunner: evaluateComments :: forall (es :: [Effect]). (HasCallStack, EvalCommentRunner :> es) => CommandTemplate 'Eval -> NonEmpty (LoadedModule, NonEmpty Comment) -> Eff es (NonEmpty Evaluation)
- Tricorder.Daemon.GhciSession: withGhci :: forall (es :: [Effect]) a. (GhciSession :> es, Pub BuildProgress :> es) => Command -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
+ Tricorder.Daemon.GhciSession: withGhci :: forall (es :: [Effect]) a. (GhciSession :> es, Pub BuildProgress :> es) => RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
- Tricorder.Daemon.GhciSession: withGhciWith :: forall a (es :: [Effect]). (HasCallStack, GhciSession :> es) => (BuildProgress -> Eff es ()) -> Command -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
+ Tricorder.Daemon.GhciSession: withGhciWith :: forall a (es :: [Effect]). (HasCallStack, GhciSession :> es) => (BuildProgress -> Eff es ()) -> RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
- Tricorder.Daemon.GhciSession.GhciProcess: UnexpectedExit :: Text -> Maybe Text -> GhciProcessError
+ Tricorder.Daemon.GhciSession.GhciProcess: UnexpectedExit :: UnexpectedExit -> GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: withGhciProcess :: forall (es :: [Effect]) a. (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es) => Config -> Command -> FilePath -> (GhciLoading -> Eff es ()) -> (GhciProcess -> Eff es ()) -> (GhciProcess -> [Text] -> Eff es a) -> Eff es a
+ Tricorder.Daemon.GhciSession.GhciProcess: withGhciProcess :: forall (es :: [Effect]) (stage :: Stage) a. (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es) => Config -> RenderedCommand stage -> FilePath -> (GhciLoading -> Eff es ()) -> (GhciProcess -> Eff es ()) -> (GhciProcess -> [Text] -> Eff es a) -> Eff es a
- Tricorder.Daemon.TestRunner: [RunTestSuite] :: forall (a :: Type -> Type). (Suite -> a ()) -> Maybe ByteSize -> Repl -> TestTimeout -> TestTarget -> TestRunner a Suite
+ Tricorder.Daemon.TestRunner: [RunTestSuite] :: forall (a :: Type -> Type). (Suite -> a ()) -> TestTimeout -> RenderedTestCommand -> TestRunner a Suite
- Tricorder.Daemon.TestRunner: runTestSuite :: forall (es :: [Effect]). (HasCallStack, TestRunner :> es) => (Suite -> Eff es ()) -> Maybe ByteSize -> Repl -> TestTimeout -> TestTarget -> Eff es Suite
+ Tricorder.Daemon.TestRunner: runTestSuite :: forall (es :: [Effect]). (HasCallStack, TestRunner :> es) => (Suite -> Eff es ()) -> TestTimeout -> RenderedTestCommand -> Eff es Suite
- Tricorder.Session: Session :: Command -> [Target] -> [TestTarget] -> Maybe ByteSize -> WatchDirs -> WatchExclusionPatterns -> ReplBuildDir -> TestTimeout -> GenerateWithHpack -> Hooks -> IdleTimeout -> Session
+ Tricorder.Session: Session :: BuildSession -> TestSession -> CommandTemplate 'Eval -> Maybe ByteSize -> WatchDirs -> WatchExclusionPatterns -> ReplBuildDir -> TestTimeout -> GenerateWithHpack -> Hooks -> IdleTimeout -> Session
- Tricorder.Session.CabalFile: inputCabalFiles :: forall (es :: [Effect]) a. (Env :> es, FileSystem :> es, Glob :> es, Log :> es, Reader ProjectRoot :> es) => Eff (Input [CabalFile] ': es) a -> Eff es a
+ Tricorder.Session.CabalFile: inputCabalFiles :: forall (es :: [Effect]) a. (Env :> es, FileSystem :> es, Glob :> es, Input StackProject :> es, Log :> es, Reader ProjectRoot :> es) => Eff (Input [CabalFile] ': es) a -> Eff es a
- Tricorder.Session.Config: Config :: Maybe Text -> [Text] -> [FilePath] -> [Text] -> Maybe [Text] -> FilePath -> Int -> Bool -> Maybe Text -> Maybe Hooks -> Int -> Config
+ Tricorder.Session.Config: Config :: CommandConfig 'Build -> TestConfig -> CommandConfig 'Eval -> [FilePath] -> [Text] -> FilePath -> Int -> Bool -> Maybe Text -> Maybe Hooks -> Int -> Maybe Text -> [Text] -> Maybe [Text] -> Config
- Tricorder.Socket.Server: main :: forall (es :: [Effect]). (Conc :> es, Exit :> es, IdleTimer :> es, Input BuildId :> es, Input DaemonInfo :> es, Log :> es, Reader SocketPath :> es, Sub BuildPhase :> es, UnixSocket :> es, Waiters :> es) => Eff es Void
+ Tricorder.Socket.Server: main :: forall (es :: [Effect]). (Conc :> es, Exit :> es, IdleTimer :> es, Input BuildId :> es, Log :> es, Reader SocketPath :> es, Sub BuildPhase :> es, UnixSocket :> es, Waiters :> es) => Eff es Void
Files
- CHANGELOG.md +41/−0
- src/Tricorder/Build.hs +1/−3
- src/Tricorder/CLI/App.hs +12/−18
- src/Tricorder/CLI/Arguments.hs +5/−14
- src/Tricorder/CLI/Arguments/Daemon.hs +14/−0
- src/Tricorder/CLI/Arguments/OutputFormat.hs +12/−0
- src/Tricorder/CLI/Main.hs +7/−2
- src/Tricorder/CLI/Operations.hs +33/−10
- src/Tricorder/CLI/UI/Keys.hs +4/−15
- src/Tricorder/CLI/UI/Route.hs +0/−2
- src/Tricorder/CLI/UI/View.hs +8/−82
- src/Tricorder/Daemon/Builder.hs +3/−2
- src/Tricorder/Daemon/Core.hs +117/−121
- src/Tricorder/Daemon/DaemonInfo.hs +2/−1
- src/Tricorder/Daemon/EvalCommentRunner.hs +11/−14
- src/Tricorder/Daemon/GhciSession.hs +4/−3
- src/Tricorder/Daemon/GhciSession/GhciProcess.hs +36/−32
- src/Tricorder/Daemon/Main.hs +5/−4
- src/Tricorder/Daemon/TestRunner.hs +181/−89
- src/Tricorder/Session.hs +132/−17
- src/Tricorder/Session/CabalFile.hs +89/−21
- src/Tricorder/Session/Command.hs +0/−141
- src/Tricorder/Session/Command/RenderedCommand.hs +9/−0
- src/Tricorder/Session/CommandConfig.hs +37/−0
- src/Tricorder/Session/CommandTemplate.hs +123/−0
- src/Tricorder/Session/Config.hs +22/−6
- src/Tricorder/Session/Repl.hs +45/−0
- src/Tricorder/Session/StackProject.hs +70/−0
- src/Tricorder/Session/Stage.hs +4/−0
- src/Tricorder/Session/Stage/Build/Command.hs +78/−0
- src/Tricorder/Session/Stage/Build/Session.hs +66/−0
- src/Tricorder/Session/Stage/Eval/Command.hs +34/−0
- src/Tricorder/Session/Stage/Eval/Session.hs +27/−0
- src/Tricorder/Session/Stage/Test/Command.hs +60/−0
- src/Tricorder/Session/Stage/Test/Config.hs +60/−0
- src/Tricorder/Session/Stage/Test/Session.hs +111/−0
- src/Tricorder/Session/Target.hs +27/−25
- src/Tricorder/Session/TestTarget.hs +28/−22
- src/Tricorder/Session/Util.hs +17/−0
- src/Tricorder/Session/WatchDirs.hs +3/−3
- src/Tricorder/Socket/Server.hs +2/−11
- src/Tricorder/SourceLookup.hs +1/−1
- src/Tricorder/SourceLookup/GhcPkg.hs +1/−1
- test/Driver.hs +1/−1
- test/Unit/Tricorder/Build/ByteSizeSpec.hs +52/−64
- test/Unit/Tricorder/Build/EvalCommentSpec.hs +102/−126
- test/Unit/Tricorder/CLI/RenderSpec.hs +26/−20
- test/Unit/Tricorder/Daemon/BuildStateSpec.hs +67/−72
- test/Unit/Tricorder/Daemon/BuilderSpec.hs +65/−70
- test/Unit/Tricorder/Daemon/DispatchSpec.hs +114/−119
- test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs +329/−322
- test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs +114/−101
- test/Unit/Tricorder/Daemon/GhciSessionSpec.hs +44/−36
- test/Unit/Tricorder/Daemon/TestRunnerSpec.hs +110/−90
- test/Unit/Tricorder/Daemon/WatchSpec.hs +64/−59
- test/Unit/Tricorder/Session/CabalFileSpec.hs +138/−69
- test/Unit/Tricorder/Session/CommandSpec.hs +0/−123
- test/Unit/Tricorder/Session/CommandTemplateSpec.hs +58/−0
- test/Unit/Tricorder/Session/ReplSpec.hs +52/−0
- test/Unit/Tricorder/Session/Stage/Build/CommandSpec.hs +193/−0
- test/Unit/Tricorder/Session/Stage/Eval/CommandSpec.hs +62/−0
- test/Unit/Tricorder/Session/Stage/Test/CommandSpec.hs +106/−0
- test/Unit/Tricorder/Session/TargetSpec.hs +221/−191
- test/Unit/Tricorder/Session/TestTargetSpec.hs +31/−29
- test/Unit/Tricorder/Session/WatchDirsSpec.hs +137/−112
- test/Unit/Tricorder/SessionSpec.hs +83/−28
- test/Unit/Tricorder/SocketSpec.hs +36/−28
- test/Unit/Tricorder/SourceLookup/GhcPkgSpec.hs +19/−16
- test/Unit/Tricorder/SourceLookup/SliceSpec.hs +312/−312
- test/Unit/Tricorder/SourceLookup/TarballSpec.hs +62/−64
- test/Unit/Tricorder/SourceLookupSpec.hs +99/−101
- test/Unit/Tricorder/TestOutputSpec.hs +168/−181
- test/Unit/Tricorder/WaitersSpec.hs +108/−100
- tricorder.cabal +25/−7
CHANGELOG.md view
@@ -7,6 +7,46 @@ ## [Unreleased] +## [0.5.0.0] - 2026-10-02++### Added++- Log test suite commands.+- `session.build`, `session.test`, and `session.eval` config sections, each+ independently configuring the command used to build the project, run test+ suites, and evaluate eval comments. See+ [Configuring Tricorder] for a complete,+ up-to-date description of these options.+- `tricorder daemon info` subcommand showing daemon info. This information was+ moved from `tricorder ui`.+- Tricorder now supports running tests with regular `cabal test` or+ `stack test`. Tricorder will automatically detect whether the tests are ran+ in GHCi or through a regular `cabal/stack test` by inspecting the configured+ `session.test.command_template`. You can force the output mode to use by+ setting `session.test.output_mode`. See [Configuring Tricorder] for more+ information.++### Changed++- Deprecate configuration options `session.command`, `session.targets` and+ `session.test_targets` in favor of `session.build.command_template`,+ `session.build.targets` and `session.test.targets`, respectively.+- Tricorder now uses `brick` `3.0` for its TUI.++### Fixed++- Multi-package Stack projects were being resolved as single-package Stack+ projects. (Thanks @marcosh!)+- When Tricorder detects that the project is a Stack project, it now correctly+ inspects `stack.yaml` for packages instead of assuming packages are listed in+ `cabal.project`.++### Removed++- `tricorder ui` no longer has the "Daemon info" tab. This tab contains+ information that is rarely necessary to have as accessible as in the TUI, so it+ has been moved to a separate `tricorder daemon info` subcommand.+ ## [0.4.1.1] - 2026-09-21 ### Fixed@@ -226,3 +266,4 @@ (fixes ghcid's crash-on-file-removal bug). [Features of Tricorder - Eval Comments]: ../docs/features-of-tricorder.md#eval-comments+[Configuring Tricorder]: /docs/configuring-tricorder.md
src/Tricorder/Build.hs view
@@ -15,7 +15,6 @@ import GHC.Generics (Generically (..)) import Tricorder.Build.Duration (Duration)-import Tricorder.Daemon.DaemonInfo (DaemonInfo) import Tricorder.Session.TestTarget (TestTarget) import Tricorder.Build.EvalComment qualified as Eval@@ -23,8 +22,7 @@ data BuildState = BuildState- { daemonInfo :: DaemonInfo- , phase :: BuildPhase+ { phase :: BuildPhase , buildId :: BuildId } deriving stock (Eq, Generic, Show)
src/Tricorder/CLI/App.hs view
@@ -8,7 +8,7 @@ import Atelier.Effects.Exit (Exit) import Atelier.Effects.File (File) import Atelier.Effects.FileSystem (FileSystem)-import Atelier.Effects.Input (Input)+import Atelier.Effects.Input (Input, input) import Atelier.Effects.Log (Log) import Atelier.Effects.Posix.Daemons (Daemons) import Atelier.Effects.Process (Process)@@ -21,8 +21,8 @@ import Atelier.Effects.Console qualified as Console import Data.Text qualified as T+import Tricorder.CLI.Command.Daemon qualified as DaemonCommand -import Tricorder.Build (BuildState (..)) import Tricorder.CLI.Arguments (Command (..), LogMode (..)) import Tricorder.CLI.Daemon ( restartDaemon@@ -31,7 +31,8 @@ , waitForDaemon ) import Tricorder.CLI.Operations- ( showEvalComments+ ( showDaemonInfo+ , showEvalComments , showLog , showSource , showStatus@@ -40,10 +41,10 @@ import Tricorder.CLI.UI (viewUi) import Tricorder.CLI.UI.Brick (Brick) import Tricorder.CLI.UI.BrickChan (BrickChan)-import Tricorder.Daemon.DaemonInfo (DaemonInfo (..))+import Tricorder.Daemon.DaemonInfo (DaemonInfo) import Tricorder.Runtime (LogPath (..), PidFile (..), SocketPath (..))-import Tricorder.Session.Command (Repl)-import Tricorder.Socket.Client (isDaemonRunning, queryStatus)+import Tricorder.Session.Repl (Repl)+import Tricorder.Socket.Client (isDaemonRunning) import Tricorder.Socket.UnixSocket (UnixSocket) import Tricorder.SourceLookup (ModuleSourceResult) import Tricorder.SourceLookup.GhcPkg (GhcPkg)@@ -71,6 +72,7 @@ , GhcPkg :> es , Hackage :> es , IOE :> es+ , Input DaemonInfo :> es , Input Repl :> es , Log :> es , PackageStore :> es@@ -124,18 +126,7 @@ else showTests opts Log logMode -> do- running <- isDaemonRunning- logFile <-- if running- then do- SocketPath sp <- ask- result <- queryStatus sp- LogPath fallback <- ask- pure $ case result of- Right state -> state.daemonInfo.logFile- Left _ -> fallback- else- asks @LogPath (.getLogPath)+ logFile <- asks @LogPath (.getLogPath) case logMode of ShowLog -> showLog logFile ShowLogPath -> Console.putTextLn (toText logFile)@@ -157,3 +148,6 @@ startDaemon void waitForDaemon showEvalComments opts+ Daemon (DaemonCommand.Info format) -> do+ daemonInfo <- input+ showDaemonInfo format daemonInfo
src/Tricorder/CLI/Arguments.hs view
@@ -1,7 +1,6 @@ module Tricorder.CLI.Arguments ( Command (..) , LogMode (..)- , OutputFormat (..) , StatusOptions (..) , TestOptions (..) , EvalCommentsOptions (..)@@ -41,7 +40,6 @@ , EvalCommentsOptions (..) , Force (..) , LogMode (..)- , OutputFormat (..) , StatusOptions (..) , TestOptions (..) , Verbosity (..)@@ -49,6 +47,8 @@ ) import Tricorder.SourceLookup.SourceQuery (SourceQuery, parseSourceQuery) +import Tricorder.CLI.Arguments.Daemon qualified as Daemon+import Tricorder.CLI.Arguments.OutputFormat qualified as OutputFormat import Tricorder.Version qualified as Version @@ -92,6 +92,7 @@ <> command "eval-comments" (info evalCommentsParser (progDesc "Show eval comments from the latest build"))+ <> command "daemon" (info (Daemon <$> Daemon.parser) (progDesc "Daemon sub-commands")) ) @@ -113,7 +114,7 @@ Status <$> ( StatusOptions <$> waitParser- <*> jsonFormatToggleParser+ <*> OutputFormat.parser <*> flag Concise Verbose@@ -171,7 +172,7 @@ EvalComments <$> ( EvalCommentsOptions <$> waitParser- <*> jsonFormatToggleParser+ <*> OutputFormat.parser ) @@ -182,16 +183,6 @@ WaitForBuild ( long "wait" <> help "Block until the current build cycle completes"- )---jsonFormatToggleParser :: Parser OutputFormat-jsonFormatToggleParser =- flag- TextOutput- JsonOutput- ( long "json"- <> help "Output full build state as JSON" )
+ src/Tricorder/CLI/Arguments/Daemon.hs view
@@ -0,0 +1,14 @@+module Tricorder.CLI.Arguments.Daemon (parser) where++import Options.Applicative (Parser, command, hsubparser, info, progDesc)+import Tricorder.CLI.Command.Daemon (DaemonCommand (..))++import Tricorder.CLI.Arguments.OutputFormat qualified as OutputFormat+++parser :: Parser DaemonCommand+parser = hsubparser (command "info" (info infoParser (progDesc "Information about the daemon")))+++infoParser :: Parser DaemonCommand+infoParser = Info <$> OutputFormat.parser
+ src/Tricorder/CLI/Arguments/OutputFormat.hs view
@@ -0,0 +1,12 @@+module Tricorder.CLI.Arguments.OutputFormat (parser) where++import Options.Applicative (Parser, flag, help, long)+import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))+++parser :: Parser OutputFormat+parser =+ flag+ TextOutput+ JsonOutput+ $ long "json" <> help "Output full build state as JSON"
src/Tricorder/CLI/Main.hs view
@@ -33,12 +33,15 @@ import Tricorder.Runtime (runLogPath, runPidFile, runProjectRoot, runRuntimeDir, runSocketPath) import Tricorder.Session (Session (..), loadSession) import Tricorder.Session.CabalFile (inputCabalFiles)-import Tricorder.Session.Command (Command (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..))+import Tricorder.Session.Stage.Build.Session (BuildSession (..)) import Tricorder.Socket.UnixSocket (runUnixSocketIO) import Tricorder.SourceLookup.PackageId (PackageId) import Tricorder.CLI.App qualified as App import Tricorder.CLI.UI.Keys qualified as Keys+import Tricorder.Daemon.DaemonInfo qualified as DaemonInfo+import Tricorder.Session.StackProject qualified as StackProject import Tricorder.SourceLookup qualified as SourceLookup import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg import Tricorder.SourceLookup.Hackage qualified as Hackage@@ -75,15 +78,17 @@ . runEnv . inputLoadedConfig . runLogNoOp+ . StackProject.inputWithCache . inputCabalFiles . runInputEff loadSession- . runInputEff ((.command.repl) <$> input)+ . runInputEff ((.buildSession.commandTemplate.repl) <$> input) . runReader @CacheConfig.Config def . Cache.runCacheTtl @ModuleName @PackageId . Cache.runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult . GhcPkg.runGhcPkgIO . PackageStore.run . Hackage.run+ . DaemonInfo.runInput $ do installTerminationHandler App.run
src/Tricorder/CLI/Operations.hs view
@@ -4,6 +4,7 @@ , showStatus , showTests , showEvalComments+ , showDaemonInfo ) where @@ -15,10 +16,11 @@ import Atelier.Effects.FileSystem (FileSystem, doesFileExist, readFileLbs) import Atelier.Effects.Input (Input) import Atelier.Effects.Log (Log)-import Data.Aeson (encode)+import Data.Aeson (ToJSON, encode) import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.LocalTime (utcToLocalTime) import Effectful.Reader.Static (Reader, ask)+import Tricorder.CLI.Command.OutputFormat (OutputFormat (..)) import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery) import Atelier.Effects.Console qualified as Console@@ -31,7 +33,6 @@ import Tricorder.Build.Test (Suites (..)) import Tricorder.CLI.Arguments ( EvalCommentsOptions (..)- , OutputFormat (..) , StatusOptions (..) , TestOptions (..) , Verbosity (..)@@ -42,9 +43,9 @@ , formatDuration , renderSourceResults )+import Tricorder.Daemon.DaemonInfo (DaemonInfo (..)) import Tricorder.Runtime (SocketPath (..))-import Tricorder.Session.Command (Repl)-import Tricorder.Session.TestTarget (renderTestTarget)+import Tricorder.Session.Repl (Repl) import Tricorder.Socket.Client (queryStatus, queryStatusWait) import Tricorder.Socket.UnixSocket (UnixSocket) import Tricorder.SourceLookup (ModuleSourceResult, lookupModuleSource)@@ -58,6 +59,8 @@ import Tricorder.Build.EvalComment qualified as Eval import Tricorder.Build.Test qualified as Test import Tricorder.Build.Test qualified as Tests+import Tricorder.Session.Target qualified as Target+import Tricorder.Session.TestTarget qualified as TestTarget -- | Print a build-command failure message and exit non-zero.@@ -147,7 +150,7 @@ mapM_ (Console.putTextLn . (" " <>)) (stripGhciNoise (T.lines c.output)) _ -> pure () where- t = renderTestTarget tgt+ t = TestTarget.render tgt buildHasErrors r = any ((== SError) . (.severity)) r.diagnostics buildSummary tz r =@@ -216,7 +219,7 @@ | Map.null suites = Console.putStrLn "No test results." | Map.null filteredSuites = do Console.putStrLn "All passed."- mapM_ (Console.putTextLn . (" " <>) . renderTestTarget) $ Map.keys suites+ mapM_ (Console.putTextLn . (" " <>) . TestTarget.render) $ Map.keys suites | otherwise = do mapM_ (uncurry printTestOutput) $ Map.toList filteredSuites when (any Test.isFailedRun filteredSuites) exitFailure@@ -248,7 +251,7 @@ else mapM_ (Console.putTextLn . (" " <>)) (stripGhciNoise (lines c.output)) where- t = renderTestTarget tgt <> " "+ t = TestTarget.render tgt <> " " printFailedCase tc = do Console.putTextLn $ " " <> tc.description@@ -293,6 +296,23 @@ JsonOutput -> displayJsonOutput result +showDaemonInfo :: (Console :> es) => OutputFormat -> DaemonInfo -> Eff es ()+showDaemonInfo format daemonInfo = case format of+ TextOutput -> text+ JsonOutput -> json+ where+ text = do+ Console.putTextLn "Targets:"+ for_ daemonInfo.targets \target ->+ Console.putTextLn $ "- " <> Target.render target+ Console.putTextLn "Watch directories:"+ for_ daemonInfo.watchDirs \dir ->+ Console.putTextLn $ "- " <> toText dir+ Console.putTextLn $ "Socket file path: " <> toText daemonInfo.sockPath+ Console.putTextLn $ "Log file path: " <> toText daemonInfo.logFile+ json = putJson daemonInfo++ displayTextOutput :: ( Console :> es , Exit :> es@@ -302,7 +322,7 @@ Left err -> do Console.putTextLn $ "Error: " <> err exitFailure- Right (BuildState _ phase _) -> case phase of+ Right (BuildState phase _) -> case phase of Build.Starting -> Console.putStrLn "Starting..." Build.Building _ _ -> Console.putStrLn "Building..." Build.PostBuilding _ postBuild -> txtEvalComments postBuild@@ -357,7 +377,7 @@ Left err -> do putJson $ Eval.Failed err exitFailure- Right (BuildState _ phase _) -> case phase of+ Right (BuildState phase _) -> case phase of Build.Starting -> putJson Eval.Starting Build.Building _ _ -> putJson Eval.Building Build.Failed msg -> do@@ -372,7 +392,10 @@ Eval.Found comments | Eval.anyRunningComments comments -> putJson Eval.Evaluating | otherwise -> putJson $ Eval.Done comments- putJson = Console.putStrLn . toStrict . encode+++putJson :: (Console :> es, ToJSON a) => a -> Eff es ()+putJson = Console.putStrLn . toStrict . encode displayPendingBuildStatus
src/Tricorder/CLI/UI/Keys.hs view
@@ -77,8 +77,7 @@ -- 'KeyEvent', update that list to match — @tagref check@ flags the dangling -- reference if this tag is renamed or dropped without touching the docs. data KeyEvent- = ToggleDaemonInfoView- | ToggleHelp+ = ToggleHelp | CycleTestView | ToggleEvalComments | RestartDaemon@@ -104,8 +103,7 @@ keys :: KeyEvents KeyEvent keys = keyEvents- [ ("toggle daemon info", ToggleDaemonInfoView)- , ("toggle help", ToggleHelp)+ [ ("toggle help", ToggleHelp) , ("cycle test view", CycleTestView) , ("toggle eval comments", ToggleEvalComments) , ("restart daemon", RestartDaemon)@@ -118,8 +116,7 @@ bindings :: [(KeyEvent, [Binding])] bindings =- [ (ToggleDaemonInfoView, [bind 'g'])- , (ToggleHelp, [bind 'h'])+ [ (ToggleHelp, [bind 'h']) , (CycleTestView, [bind 't']) , (ToggleEvalComments, [bind 'e']) , (RestartDaemon, [bind 'R'])@@ -181,14 +178,7 @@ either (error . ("Invalid key dispatcher config: " <>) . stringify) id $ keyDispatcher cfg- [ onEvent ToggleDaemonInfoView "Toggle daemon info view" do- modify \s ->- if currentRoute s == Route.DaemonInfo- then- navigate Route.Main s- else- navigate Route.DaemonInfo s- , onEvent ToggleHelp "Toggle help" do+ [ onEvent ToggleHelp "Toggle help" do modify \s -> if currentRoute s == Route.Help then@@ -263,7 +253,6 @@ keybindForRoute :: KeyConfig KeyEvent -> Route -> Maybe Binding keybindForRoute kc = \case Route.Main -> Nothing- Route.DaemonInfo -> firstActiveBinding kc ToggleDaemonInfoView Route.Help -> firstActiveBinding kc ToggleHelp Route.Tests -> firstActiveBinding kc CycleTestView Route.Evals -> firstActiveBinding kc ToggleEvalComments
src/Tricorder/CLI/UI/Route.hs view
@@ -8,7 +8,6 @@ data Route = Main | Help- | DaemonInfo | Tests | Evals deriving stock (Bounded, Enum, Eq)@@ -18,6 +17,5 @@ name = \case Main -> "Dashboard" Help -> "Help"- DaemonInfo -> "Daemon info" Tests -> "Tests" Evals -> "Eval comments"
src/Tricorder/CLI/UI/View.hs view
@@ -26,7 +26,6 @@ , withVScrollBars ) import Data.Time (UTCTime, defaultTimeLocale, formatTime, utcToLocalTime)-import System.FilePath (isAbsolute) import Data.Map.Strict qualified as Map import Data.Text qualified as T@@ -45,9 +44,7 @@ , Viewports (..) , currentRoute )-import Tricorder.Daemon.DaemonInfo (DaemonInfo (..))-import Tricorder.Session.Target (Target, renderTarget)-import Tricorder.Session.TestTarget (TestTarget, renderTestTarget)+import Tricorder.Session.TestTarget (TestTarget) import Tricorder.TestOutput (stripGhciNoise) import Tricorder.Build qualified as Build@@ -55,7 +52,7 @@ import Tricorder.Build.Test qualified as Test import Tricorder.CLI.UI.Keys qualified as Keys import Tricorder.CLI.UI.Route qualified as Route-import Tricorder.Version qualified as Version+import Tricorder.Session.TestTarget qualified as TestTarget mkAttrMap :: State -> AttrMap@@ -83,8 +80,6 @@ , case currentRoute ws of Route.Help -> viewHelp kc- Route.DaemonInfo ->- viewDaemonInfo ws Route.Tests -> viewTests ws Route.Main ->@@ -111,19 +106,12 @@ keyBind = maybe "" showBinding $ keybindForRoute kc route -viewDaemonInfo :: State -> Widget Viewports-viewDaemonInfo ws =- withBuildState ws (viewExpandedDaemonInfo . (.daemonInfo))-- viewTests :: State -> Widget Viewports-viewTests ws =- withBuildState ws (viewTestResultsPanel ws)+viewTests ws = withBuildState ws (viewTestResultsPanel ws) viewEvals :: State -> Widget Viewports-viewEvals ws =- withBuildState ws $ viewEvalCommentsPanel ws+viewEvals ws = withBuildState ws $ viewEvalCommentsPanel ws viewMain :: State -> Widget Viewports@@ -167,7 +155,6 @@ TestFilterAll -> Just "Tests" TestFilterFailedOnly -> Just "Tests - Failed only" Route.Help -> Just "Help"- Route.DaemonInfo -> Just "Daemon info" Route.Main -> Nothing Route.Evals -> Just "Eval comments" @@ -228,67 +215,6 @@ ] -viewExpandedDaemonInfo :: DaemonInfo -> Widget n-viewExpandedDaemonInfo di =- vBox- [ viewVersion- , viewTargets di.targets- , viewWatchDirs di.watchDirs- , viewSockPath di.sockPath- , viewLogFile di.logFile- ]---viewVersion :: Widget n-viewVersion =- hBoxSpaced- 1- [ emphasis $ txt "Client version:"- , txt Version.gitHash- ]---viewTargets :: [Target] -> Widget n-viewTargets targets =- hBoxSpaced- 1- [ emphasis $ txt "Targets:"- , if null targets- then- txt "(all)"- else- vBox $ (map (txt . renderTarget) targets)- ]---viewLogFile :: FilePath -> Widget n-viewLogFile p = hBoxSpaced 1 [emphasis $ txt "Log:", txt $ toText p]---viewSockPath :: FilePath -> Widget n-viewSockPath sockPath =- hBoxSpaced 1 [emphasis $ txt "Socket:", txt $ toText sockPath]---viewWatchDirs :: [FilePath] -> Widget n-viewWatchDirs watchDirs =- vBox- [ emphasis $ txt "Watching:"- , padLeft (Pad 2)- $ vBox- $ viewWatchDir <$> watchDirs- ]---viewWatchDir :: FilePath -> Widget n-viewWatchDir dir = hBox [txt "- ", txt $ toText displayDir]- where- displayDir- | isAbsolute dir = dir- | dir == "." = "./"- | otherwise = "./" <> dir-- viewBuildPhase :: TimeZone -> BuildPhase -> Widget Viewports viewBuildPhase tz = \case Build.Starting ->@@ -320,7 +246,7 @@ viewPendingTestTargets tgts = vBox . ([txt "Pending test suites:"] <>)- . fmap (subtle . txt . renderTestTarget)+ . fmap (subtle . txt . TestTarget.render) $ tgts @@ -404,7 +330,7 @@ viewTestRun :: TestTarget -> Test.Suite -> Widget n viewTestRun tgt run =- hBox $ [txt $ renderTestTarget tgt, txt " "] <> status+ hBox $ [txt $ TestTarget.render tgt, txt " "] <> status where status = case run of Test.SuiteRunning Nothing ->@@ -459,7 +385,7 @@ -- | Single-line build status with no scrollable diagnostics list, used as a--- compact header when a secondary panel (test results, daemon info) is open.+-- compact header when a secondary panel (test results) is open. viewBuildPhaseLine :: TimeZone -> BuildPhase -> Widget n viewBuildPhaseLine tz = \case Build.Starting ->@@ -541,7 +467,7 @@ , viewTestOutput tvf c ] where- t = renderTestTarget tgt+ t = TestTarget.render tgt viewTestOutput :: TestFilter -> Test.SuiteCompletion -> Widget n
src/Tricorder/Daemon/Builder.hs view
@@ -47,10 +47,11 @@ import Tricorder.Daemon.GhciSession (GhciSession, LoadResult (..)) import Tricorder.Daemon.GhciSession.GhciParser (resolveKnownTargets) import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command (Command)+import Tricorder.Session.Command.RenderedCommand (RenderedCommand) import Tricorder.Session.WatchDirs (WatchDirs) import Tricorder.Daemon.GhciSession qualified as GhciSession+import Tricorder.Session.Stage qualified as Stage data Builder :: Effect where@@ -87,7 +88,7 @@ , Reader ProjectRoot :> es ) => BuildId- -> Command+ -> RenderedCommand 'Stage.Build -> WatchDirs -> (BuilderState -> NewLoadResult -> Eff (Builder : es) a) -> Eff es (Either SomeException a)
src/Tricorder/Daemon/Core.hs view
@@ -19,7 +19,6 @@ import Effectful.State.Static.Shared (State) import Relude.Extra.Tuple (dup) import System.FilePath ((</>))-import Text.Regex.TDFA.Pattern (showPattern) import Atelier.Effects.Conc qualified as Conc import Atelier.Effects.FileSystem qualified as FileSystem@@ -28,7 +27,6 @@ import Atelier.Effects.Publishing.Pub qualified as Pub import Atelier.Effects.Publishing.Sub qualified as Sub import Data.Map.Strict qualified as Map-import Data.Text qualified as T import Effectful.Reader.Static qualified as Reader import Effectful.State.Static.Shared qualified as State @@ -42,15 +40,8 @@ , NewLoadResult , compileBuildResults )-import Tricorder.Daemon.Dispatch- ( BuilderState (..)- , DispatchAction- , emptyBuilderState- )-import Tricorder.Daemon.EvalCommentRunner- ( EvalCommentRunner- , findEvalCommentsInModules- )+import Tricorder.Daemon.Dispatch (BuilderState (..), DispatchAction, emptyBuilderState)+import Tricorder.Daemon.EvalCommentRunner (EvalCommentRunner, findEvalCommentsInModules) import Tricorder.Daemon.GhciSession (GhciSession) import Tricorder.Daemon.GhciSession.GhciParser ( LoadResult@@ -62,14 +53,13 @@ import Tricorder.Daemon.Watch (WatchedFile) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session (Session (..))-import Tricorder.Session.Command (Command (..), Repl) import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..)) import Tricorder.Session.IdleTimeout (IdleTimeout)-import Tricorder.Session.ReplBuildDir (ReplBuildDir (..))-import Tricorder.Session.TestTarget (TestTarget, renderTestTarget)+import Tricorder.Session.Repl (Repl)+import Tricorder.Session.Stage.Build.Session (BuildSession (..))+import Tricorder.Session.Stage.Test.Command (RenderedTestCommand (..))+import Tricorder.Session.Stage.Test.Session (TestSession (..)) import Tricorder.Session.TestTimeout (TestTimeout (..))-import Tricorder.Session.WatchDirs (WatchDirs (..))-import Tricorder.Session.WatchExclusionPatterns (WatchExclusionPatterns (..)) import Tricorder.Waiters (Waiters) import Tricorder.Build qualified as Build@@ -81,9 +71,11 @@ import Tricorder.Daemon.Hpack qualified as Hpack import Tricorder.Daemon.TestRunner qualified as TestRunner import Tricorder.Daemon.Watch qualified as Watch-import Tricorder.Session.Command qualified as Command+import Tricorder.Session qualified as Session+import Tricorder.Session.CommandTemplate qualified as Command import Tricorder.Session.Hooks qualified as Hooks-import Tricorder.Session.Target qualified as Target+import Tricorder.Session.Stage.Build.Command qualified as BuildCommand+import Tricorder.Session.Stage.Test.Command qualified as TestCommand import Tricorder.Session.TestTarget qualified as TestTarget import Tricorder.Waiters qualified as Waiters @@ -122,44 +114,88 @@ , Waiters :> es ) => Eff es Void-main = runPubSub @ReloadSession- . runPubSub @WatchedFile- . runPubSub @CabalChangeDetected- . runPubSub @SourceChangeDetected- . runPubSub @RestartBuilder- . runPubSub @ReloadBuilder- $ Conc.restartableFork waitForReloadSession do- root <- Reader.ask- session <- input- logSession session+main =+ runPubSub @ReloadSession+ . runPubSub @WatchedFile+ . runPubSub @CabalChangeDetected+ . runPubSub @SourceChangeDetected+ . runPubSub @RestartBuilder+ . runPubSub @ReloadBuilder+ . Conc.restartableFork waitForReloadSession+ $ do+ Log.debug "Before session"+ session <- input+ Log.debug "After session"+ withSession session+ where+ waitForReloadSession = Waiters.wait $ Sub.listenOnce_ @ReloadSession - State.put session.command.repl- State.put session.idleTimeout- Conc.fork_ $ watchConfigFile root- conditionallyWatchStackYaml root - Conc.fork_ $ Watch.files root session- Conc.fork_ $ Sub.listen_ Watch.publishChange+withSession+ :: ( Chan :> es+ , Clock :> es+ , Conc :> es+ , Concurrent :> es+ , Debounce FilePath :> es+ , EvalCommentRunner :> es+ , FileSystem :> es+ , FileWatcher :> es+ , GhciSession :> es+ , Hpack :> es+ , Input Session :> es+ , Log :> es+ , Process :> es+ , Pub BuildPhase :> es+ , Pub CabalChangeDetected :> es+ , Pub ReloadBuilder :> es+ , Pub ReloadSession :> es+ , Pub RestartBuilder :> es+ , Pub SourceChangeDetected :> es+ , Pub WatchedFile :> es+ , Reader ProjectRoot :> es+ , State BuildId :> es+ , State IdleTimeout :> es+ , State Repl :> es+ , Sub CabalChangeDetected :> es+ , Sub ReloadBuilder :> es+ , Sub RestartBuilder :> es+ , Sub SourceChangeDetected :> es+ , Sub WatchedFile :> es+ , TestRunner :> es+ , Waiters :> es+ )+ => Session -> Eff es Void+withSession session = do+ root <- Reader.ask+ logSession session - Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) -> do- needsSessionReload <- shouldReloadSession session- if needsSessionReload- then- Pub.publish ReloadSession- else- Pub.publish RestartBuilder+ State.put session.buildSession.commandTemplate.repl+ State.put session.idleTimeout+ Conc.fork_ $ watchConfigFile root+ conditionallyWatchStackYaml root - Conc.fork_ $ Sub.listen_ \(SourceChangeDetected fp event) ->- Pub.publish $ ReloadBuilder fp event+ Conc.fork_ $ Watch.files root session+ Conc.fork_ $ Sub.listen_ Watch.publishChange - when session.generateWithHpack.getGenerateWithHpack do- void $ Conc.fork Hpack.main+ Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) ->+ ifM+ (shouldReloadSession session)+ (Pub.publish ReloadSession)+ (Pub.publish RestartBuilder) - State.evalState emptyBuilderState $ withSession session- where- waitForReloadSession = Waiters.wait $ Sub.listenOnce_ @ReloadSession+ Conc.fork_ $ Sub.listen_ \(SourceChangeDetected fp event) ->+ Pub.publish $ ReloadBuilder fp event + when (coerce session.generateWithHpack)+ $ void+ $ Conc.fork Hpack.main + State.evalState emptyBuilderState do+ Conc.restartableFork (Waiters.wait $ Sub.listenOnce_ @RestartBuilder) do+ buildId <- State.state (\s -> (s, s + 1))+ runBuilder buildId session++ shouldReloadSession :: (Input Session :> es) => Session -> Eff es Bool shouldReloadSession oldSession = do newSession <- input@@ -194,34 +230,8 @@ \_ _ -> Pub.publish RestartBuilder --- | For a given session, handles controlling the build process itself,--- restarting it as necessary.-withSession- :: ( Clock :> es- , Conc :> es- , Concurrent :> es- , EvalCommentRunner :> es- , GhciSession :> es- , Log :> es- , Process :> es- , Pub BuildPhase :> es- , Reader ProjectRoot :> es- , State BuildId :> es- , State BuilderState :> es- , Sub ReloadBuilder :> es- , Sub RestartBuilder :> es- , TestRunner :> es- , Waiters :> es- )- => Session -> Eff es Void-withSession session = do- Conc.restartableFork (Waiters.wait $ Sub.listenOnce_ @RestartBuilder) do- buildId <- State.state (\s -> (s, s + 1))- runSession buildId session-- -- | Starts the initial build with GHCi, and waits for source changes.-runSession+runBuilder :: ( Clock :> es , Conc :> es , Concurrent :> es@@ -237,13 +247,13 @@ , Waiters :> es ) => BuildId -> Session -> Eff es ()-runSession buildId session = do+runBuilder buildId session = do Log.info $ "Starting session " <> show buildId.getBuildId Pub.publish Build.Starting whenJust (session.hooks.start >>= (.before)) Hooks.runHook startupError <- fmap (either id absurd)- $ Pub.map (Build.Building session.testTargets)- $ Builder.with buildId session.command session.watchDirs \_ initialLoad -> do+ $ Pub.map (Build.Building session.testSession.targets)+ $ Builder.with buildId buildCommand session.watchDirs \_ initialLoad -> do whenJust (session.hooks.start >>= (.after)) Hooks.runHook processPostBuild session $ Right initialLoad Log.debug "Waiting for reload"@@ -263,6 +273,8 @@ $ waitForReload session event Pub.publish $ Build.Failed $ show startupError+ where+ buildCommand = BuildCommand.render session.buildSession.commandTemplate session.buildSession.targets -- | Handles source changes as they come, determining whether the source change@@ -374,7 +386,7 @@ let pendingComments = sconcat $ (\(lm, ecs) -> toPending lm.relPath <$> ecs) <$> nonEmptyComments Pub.publish $ Eval.Found $ Eval.Comments pendingComments- evaluatedComments <- EvalCommentRunner.evaluateComments session.command.repl nonEmptyComments+ evaluatedComments <- EvalCommentRunner.evaluateComments session.evalSession nonEmptyComments pure $ Eval.Found $ Eval.Comments evaluatedComments where toPending file comment =@@ -392,12 +404,11 @@ ) => Session -> BuildResult -> Eff es Test.Suites runTests session buildResult- | hasTargets session.testTargets && noErrors buildResult.diagnostics =+ | hasTargets session.testSession.targets && noErrors buildResult.diagnostics = runTestsForTargets- session.command+ session.testSession session.testMemoryLimit session.testTimeout- session.testTargets | otherwise = pure mempty where hasTargets = not . null@@ -409,31 +420,37 @@ , Pub Test.Suites :> es , TestRunner :> es )- => Command+ => TestSession -> Maybe ByteSize -> TestTimeout- -> [TestTarget] -> Eff es Test.Suites-runTestsForTargets command memoryLimit testTimeout testTargets = do+runTestsForTargets testSession memoryLimit testTimeout = do Pub.publish $ Test.Suites initial- Log.info $ "Running " <> show (length testTargets) <> " test suite(s)"- fmap Test.Suites . State.execState initial $ traverse_ go testTargets+ Log.info $ "Running " <> show (length testSession.targets) <> " test suite(s)"+ fmap Test.Suites . State.execState initial $ traverse_ go testSession.targets where- initial = Map.fromList $ (,Test.SuiteRunning Nothing) <$> testTargets+ initial = Map.fromList $ (,Test.SuiteRunning Nothing) <$> testSession.targets go target = do- Log.info $ "Running tests: " <> renderTestTarget target- finishedSuite <-- TestRunner.runTestSuite- ( \suite -> do- updated <- State.state $ dup . Map.insert target suite- Pub.publish $ Test.Suites updated- )- memoryLimit- command.repl- testTimeout- target+ Log.info $ "Running tests: " <> renderedTarget+ let testCommand = TestCommand.render testSession memoryLimit target+ publishProgress suite = do+ updated <- State.state $ dup . Map.insert target suite+ Pub.publish $ Test.Suites updated+ Log.info+ $ "Test suite "+ <> renderedTarget+ <> " command:\n"+ <> show testCommand.command+ finishedSuite <- TestRunner.runTestSuite publishProgress testTimeout testCommand+ case finishedSuite of+ Test.SuiteErrored (Test.SuiteError message) ->+ Log.warn $ "Test suite " <> renderedTarget <> "failed: " <> message+ _ ->+ pure () updated <- State.state $ dup . Map.insert target finishedSuite Pub.publish $ Test.Suites updated+ where+ renderedTarget = TestTarget.render target newLoadResultToBuildResult@@ -452,25 +469,4 @@ logSession :: (Log :> es) => Session -> Eff es ()-logSession session =- Log.info- $ T.intercalate- "\n"- [ "Loaded session"- , "Command: " <> Command.render session.command- , "Targets:"- , showList Target.renderTarget session.targets- , "Test targets:"- , showList TestTarget.renderTestTarget session.testTargets- , "Watch dirs:"- , showList toText session.watchDirs.getWatchDirs- , "Watch exclusion patterns:"- , showList (toText . showPattern . fst) session.watchExclusionPatterns.getWatchExclusionPatterns- , "Repl build dir: " <> toText session.replBuildDir.getReplBuildDir- , -- TODO: Remove " seconds" when TestTimeout is converted to a proper time unit.- "Test timeout: " <> show session.testTimeout.getTestTimeout <> " seconds"- , "Test memory limit: " <> show session.testMemoryLimit- , "Generate with hpack: " <> show session.generateWithHpack.getGenerateWithHpack- ]- where- showList f = T.intercalate "\n" . fmap (("- " <>) . f)+logSession = Log.info . (<> "\n") . Session.show
src/Tricorder/Daemon/DaemonInfo.hs view
@@ -13,6 +13,7 @@ import Tricorder.Runtime (LogPath (..), ProjectRoot (..), SocketPath (..)) import Tricorder.Session (Session (..))+import Tricorder.Session.Stage.Build.Session (BuildSession (..)) import Tricorder.Session.Target (Target) import Tricorder.Session.WatchDirs (WatchDirs (..)) @@ -41,7 +42,7 @@ LogPath logFile <- ask pure $ DaemonInfo- { targets = session.targets+ { targets = session.buildSession.targets , watchDirs = map (makeRelative projectRoot) session.watchDirs.getWatchDirs , sockPath , logFile
src/Tricorder/Daemon/EvalCommentRunner.hs view
@@ -32,9 +32,11 @@ import Tricorder.Daemon.GhciSession.GhciParser (LoadedModule (..)) import Tricorder.Daemon.GhciSession.GhciProcess (execGhci, withGhciProcess) import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command (Command (..), Repl)+import Tricorder.Session.CommandTemplate (CommandTemplate) import Tricorder.Build.EvalComment qualified as Eval+import Tricorder.Session.Stage qualified as Stage+import Tricorder.Session.Stage.Eval.Command qualified as EvalCommand import Tricorder.Session.Target qualified as Target @@ -42,7 +44,7 @@ -- | Scan all loaded source files for eval comments and evaluate them, each -- in a fresh GHCi session started in that file's module context. EvaluateComments- :: Repl+ :: CommandTemplate 'Stage.Eval -> NonEmpty (LoadedModule, NonEmpty Eval.Comment) -> EvalCommentRunner m (NonEmpty Eval.Evaluation) -- | Extract eval comments from provided source files. Returns a map of all@@ -80,13 +82,9 @@ case Eval.findComments $ decodeUtf8Lenient bs of [] -> [] x : xs -> [(lm, x :| xs)]- EvaluateComments repl moduleComments -> do- fmap sconcat $ for moduleComments \(lm, comments) -> do- runFileEvals- repl- lm.relPath- lm.moduleName- comments+ EvaluateComments template moduleComments -> do+ fmap sconcat $ for moduleComments \(lm, comments) ->+ runFileEvals template lm.relPath lm.moduleName comments -- ---------------------------------------------------------------------------@@ -108,23 +106,22 @@ , Reader ProjectRoot :> es , Timeout :> es )- => Repl+ => CommandTemplate 'Stage.Eval -> FilePath -- ^ Relative path to the source file (stored in results). -> Text- -- ^ Module name (e.g. @"Tricorder.Builder"@), used to load the module- -- in interpreted mode so that its full local scope is available. -> NonEmpty Eval.Comment -> Eff es (NonEmpty Eval.Evaluation)-runFileEvals repl relPath moduleName comments = do+runFileEvals template relPath moduleName comments = do ProjectRoot projectRoot <- ask let noProgress = \_ -> pure () noSetup = \_ -> pure () wrapForGhci expr | T.elem '\n' expr = ":{" <> "\n" <> expr <> "\n" <> ":}" | otherwise = expr+ command = EvalCommand.render template [Target.Bare moduleName] sessionResult <- trySync- $ withGhciProcess def (Command repl [] [Target.Bare moduleName]) projectRoot noProgress noSetup \ghci _ -> do+ $ withGhciProcess def command projectRoot noProgress noSetup \ghci _ -> do _ <- execGhci ghci (":m *" <> moduleName) noProgress for comments \comment -> do outputResult <- trySync $ execGhci ghci (wrapForGhci comment.expression) noProgress
src/Tricorder/Daemon/GhciSession.hs view
@@ -59,7 +59,8 @@ , withGhciProcess ) import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command (Command)+import Tricorder.Session.Command.RenderedCommand (RenderedCommand)+import Tricorder.Session.Stage (Stage (..)) data GhciSession :: Effect where@@ -70,7 +71,7 @@ WithGhciWith :: (BuildProgress -> m ()) -- ^ Action to run when reporting progress- -> Command+ -> RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls m -> m a) -> GhciSession m a@@ -99,7 +100,7 @@ withGhci :: (GhciSession :> es, Pub BuildProgress :> es)- => Command+ => RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
src/Tricorder/Daemon/GhciSession/GhciProcess.hs view
@@ -2,6 +2,7 @@ ( Config (..) , GhciProcess (..) , GhciProcessError (..)+ , UnexpectedExit (..) , SessionState (..) , InterruptDecision (..) , decideInterrupt@@ -59,9 +60,7 @@ , stripAnsi , unattributedFailure )-import Tricorder.Session.Command (Command (..))--import Tricorder.Session.Command qualified as Command+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..)) -- | Configuration for GHCi process management.@@ -122,7 +121,7 @@ -- | Errors that can occur during GHCi process management. data GhciProcessError = StartupTimeout- | UnexpectedExit Text (Maybe Text)+ | UnexpectedExit UnexpectedExit | -- | The build command exited (or printed nothing parseable) before GHCi -- produced its version banner. The 'Text' is the captured stderr+stdout -- output so callers can surface a useful error (e.g. cabal's dependency@@ -131,6 +130,13 @@ deriving stock (Eq, Show) +data UnexpectedExit = MkUnexpectedExit+ { marker :: Text+ , errorMessage :: Text+ }+ deriving stock (Eq, Show)++ instance Exception GhciProcessError @@ -223,7 +229,7 @@ withGhciProcess :: (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es) => Config- -> Command+ -> RenderedCommand stage -> FilePath -> (GhciLoading -> Eff es ()) -> (GhciProcess -> Eff es ())@@ -240,8 +246,7 @@ $ setStderr createPipe $ setWorkingDir dir $ shell- $ toString- $ Command.render cmd+ $ toString cmd.getRenderedCommand -- | Execute a command in GHCi and return the combined stdout+stderr output@@ -384,32 +389,31 @@ case result of Left ex -> do let accumulatedLines = T.intercalate "\n" $ toList acc- Log.err- $ T.intercalate- "\n"- [ "Reached EOF before reading marker from GHCi."- , "Was looking for marker '" <> marker <> "', but no such marker was found."- , ""- ]- <> if T.null accumulatedLines- then- T.intercalate- "\n"- [ "GHCi returned no output before we reached what we believe is EOF."- , "Got the following exception when attempting to read from GHCi:"- , toText $ displayException ex- ]- else- T.intercalate- "\n"- [ "Accumulated output from GHCi so far:"- , accumulatedLines- ]+ errorMessage =+ T.intercalate+ "\n"+ [ "Reached EOF before reading marker from GHCi."+ , "Was looking for marker '" <> marker <> "', but no such marker was found."+ , ""+ ]+ <> if T.null accumulatedLines+ then+ T.intercalate+ "\n"+ [ "GHCi returned no output before we reached what we believe is EOF."+ , "Got the following exception when attempting to read from GHCi:"+ , toText $ displayException ex+ ]+ else+ T.intercalate+ "\n"+ [ "Accumulated output from GHCi so far:"+ , accumulatedLines+ ]+ Log.err errorMessage throwIO- $ UnexpectedExit marker- $ if T.null accumulatedLines- then Nothing- else Just accumulatedLines+ $ UnexpectedExit+ $ MkUnexpectedExit {marker, errorMessage} Right line | marker `T.isInfixOf` line -> pure $ toList acc -- A stale marker from an interrupted command: drop it, keep going.
src/Tricorder/Daemon/Main.hs view
@@ -36,17 +36,18 @@ import Tricorder.Session (inputSession) import Tricorder.Session.CabalFile (inputCabalFiles) import Tricorder.Session.IdleTimeout (IdleTimeout)+import Tricorder.Session.Repl (Repl) import Tricorder.Socket.UnixSocket (runUnixSocketIO) import Tricorder.SourceLookup.GhcPkg (runGhcPkgIO) import Tricorder.SourceLookup.PackageId (PackageId) import Tricorder.Daemon.Core qualified as Core-import Tricorder.Daemon.DaemonInfo qualified as DaemonInfo import Tricorder.Daemon.EvalCommentRunner qualified as EvalCommentRunner import Tricorder.Daemon.Hpack.Effect qualified as Hpack import Tricorder.Daemon.IdleTimer qualified as IdleTimer import Tricorder.Daemon.TestRunner qualified as TestRunner-import Tricorder.Session.Command qualified as Repl+import Tricorder.Session.Repl qualified as Repl+import Tricorder.Session.StackProject qualified as StackProject import Tricorder.Socket.Server qualified as Server import Tricorder.SourceLookup qualified as SourceLookup import Tricorder.SourceLookup.Hackage qualified as Hackage@@ -79,10 +80,10 @@ . inputLoadedConfig . runLogging . runChan+ . StackProject.inputWithCache . inputCabalFiles . inputSession . runReader @CacheConfig.Config def- . DaemonInfo.runInput . runCacheTtl @ModuleName @PackageId . runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult . runProcessIO@@ -92,7 +93,7 @@ . evalState (BuildId 1) . Input.fromState @BuildId . evalState Repl.Unknown- . Input.fromState @Repl.Repl+ . Input.fromState @Repl . evalState @IdleTimeout def . Input.fromState @IdleTimeout . IdleTimer.quitOnTimeout
src/Tricorder/Daemon/TestRunner.hs view
@@ -19,36 +19,56 @@ import Atelier.Effects.Conc (Conc) import Atelier.Effects.File (File) import Atelier.Effects.Log (Log)-import Atelier.Effects.Process (Process)+import Atelier.Effects.Process+ ( Process+ , createPipe+ , getStderr+ , getStdin+ , getStdout+ , setStderr+ , setStdin+ , setStdout+ , setWorkingDir+ , shell+ ) import Atelier.Effects.Timeout (Timeout, timeout)+import Control.Concurrent.STM (modifyTVar') import Control.Exception (throwIO) import Data.Default (def)+import Data.Sequence ((|>)) import Data.Time.Units (Second) import Effectful (Effect, IOE, Limit (..), Persistence (..), UnliftStrategy (ConcUnlift)) import Effectful.Concurrent (Concurrent)+import Effectful.Concurrent.STM (atomically, newTVarIO, readTVarIO) import Effectful.Dispatch.Dynamic (interpretWith, localUnlift, reinterpret_) import Effectful.Exception (trySync) import Effectful.Reader.Static (Reader, ask) import Effectful.State.Static.Shared (State, evalState, get, put) import Effectful.TH (makeEffect)+import System.Exit (ExitCode (..)) -import Atelier.Effects.Log qualified as Log+import Atelier.Effects.Conc qualified as Conc+import Atelier.Effects.File qualified as File+import Atelier.Effects.Process qualified as Process import Data.List qualified as List import Data.Text qualified as T -import Tricorder.Build.ByteSize (ByteSize)-import Tricorder.Daemon.GhciSession.GhciParser (GhciLoading (..))+import Tricorder.Daemon.GhciSession.GhciParser (GhciLoading (..), parseProgressLine) import Tricorder.Daemon.GhciSession.GhciProcess- ( execGhci+ ( GhciProcessError (..)+ , UnexpectedExit (..)+ , execGhci , withGhciProcess ) import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command (Command (..), Repl (..))-import Tricorder.Session.TestTarget (TestTarget, getTestTarget, renderTestTarget)+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Stage.Test.Command (RenderedTestCommand (..))+import Tricorder.Session.Stage.Test.Config (OutputMode (..))+import Tricorder.Session.Stage.Test.Session (ResolvedTestOptions (..)) import Tricorder.Session.TestTimeout (TestTimeout (..)) import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput) -import Tricorder.Build.ByteSize qualified as ByteSize import Tricorder.Build.Test qualified as Test @@ -58,11 +78,8 @@ RunTestSuite :: (Test.Suite -> m ()) -- ^ Handler for test run progress- -> Maybe ByteSize- -- ^ Memory limit for test suite- -> Repl -> TestTimeout- -> TestTarget+ -> RenderedTestCommand -> TestRunner m Test.Suite @@ -84,86 +101,161 @@ => Eff (TestRunner : es) a -> Eff es a run act = do interpretWith act \env -> \case- RunTestSuite progressHandler mMemoryLimit repl testTimeout target ->+ RunTestSuite progressHandler testTimeout cmd -> localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do let onProgress = unlift . progressHandler . loadingToProgress- noProgress _ = pure ()- noReady _ = pure ()- memoryLimitArg =- maybe- []- ( \limit ->- let- stack =- [ "--ghc-options"- , "+RTS -M"- <> ByteSize.toRTSSize limit- <> " -RTS"- ]- cabal =- [ "--repl-options"- , "+RTS -M"- <> ByteSize.toRTSSize limit- <> " -RTS"- ]- in- case repl of- Stack -> stack- StackMulti -> stack- Cabal -> cabal- Unknown -> cabal- )- mMemoryLimit- ProjectRoot projectRoot <- ask- result <- trySync- $ withGhciProcess- def- (Command repl memoryLimitArg [getTestTarget target])- projectRoot- onProgress- noReady- \ghci _ ->- case testTimeout of- TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress- TestTimeout secs ->- let duration = fromIntegral secs :: Second- in maybeToRight secs- <$> timeout duration (execGhci ghci ":main" noProgress)- case result of- Left ex ->- pure- $ Test.SuiteErrored- $ Test.SuiteError {message = show ex}- Right (Left secs) -> do- Log.warn- $ mconcat- [ "Test suite "- , renderTestTarget target- , " timed out after "- , show secs- , "s"- ]- pure- $ Test.SuiteErrored- $ Test.SuiteError- { message = "Test suite timed out after " <> show secs <> "s"- }- Right (Right mainLines) ->- pure- $ let output = T.unlines mainLines- in case detectOutcome output of- GhciCrashed msg ->- Test.SuiteErrored $ Test.SuiteError {message = msg}- outcome ->- Test.SuiteCompleted- $ Test.SuiteCompletion- { passed = outcome == GhciPassed- , output- , testCases = parseHspecOutput output- , duration = parseHspecDuration output- }+ case cmd.options.outputMode of+ ReplOutput -> runTestSuiteWithGHCi onProgress testTimeout cmd.command+ StdoutOutput -> runTestSuiteWithStdout onProgress testTimeout cmd.command +runTestSuiteWithStdout+ :: ( Conc :> es+ , Concurrent :> es+ , File :> es+ , Process :> es+ , Reader ProjectRoot :> es+ , Timeout :> es+ )+ => (GhciLoading -> Eff es ())+ -> TestTimeout+ -> RenderedCommand 'Test+ -> Eff es Test.Suite+runTestSuiteWithStdout onProgress testTimeout cmd = do+ ProjectRoot projectRoot <- ask+ -- Lines from both streams, in the order they arrived.+ outputVar <- newTVarIO (mempty :: Seq Text)+ let processConfig =+ setStdin createPipe+ $ setStdout createPipe+ $ setStderr createPipe+ $ setWorkingDir projectRoot+ $ shell+ $ toString cmd.getRenderedCommand++ drain h =+ unlessM (File.hIsEOF h) do+ line <- File.hGetLine h+ atomically $ modifyTVar' outputVar (|> line)+ traverse_ onProgress (parseProgressLine line)+ drain h++ runToCompletion p = do+ -- The suite gets no input; close stdin so it sees EOF if it reads.+ File.hClose (getStdin p)+ Conc.scoped do+ stdoutThread <- Conc.fork $ drain (getStdout p)+ stderrThread <- Conc.fork $ drain (getStderr p)+ Conc.await stdoutThread+ Conc.await stderrThread+ Process.waitExitCode p++ result <- trySync+ $ Process.withProcessGroup processConfig \p ->+ case testTimeout of+ TestTimeout secs | secs <= 0 -> Right <$> runToCompletion p+ TestTimeout secs ->+ let duration = fromIntegral secs :: Second+ in maybeToRight secs <$> timeout duration (runToCompletion p)+ output <- T.unlines . toList <$> readTVarIO outputVar+ let completed passed =+ Test.SuiteCompleted+ $ Test.SuiteCompletion+ { passed+ , output+ , testCases = parseHspecOutput output+ , duration = parseHspecDuration output+ }+ pure $ case result of+ Left ex ->+ Test.SuiteErrored+ $ Test.SuiteError+ { message = "Test suite failed to run:\n" <> show ex+ }+ Right (Left secs) ->+ Test.SuiteErrored+ $ Test.SuiteError+ { message = "Test suite timed out after " <> show secs <> "s"+ }+ Right (Right ExitSuccess) -> completed True+ Right (Right (ExitFailure _)) -> case detectOutcome output of+ GhciCrashed msg -> Test.SuiteErrored $ Test.SuiteError {message = msg}+ _ -> completed False+++runTestSuiteWithGHCi+ :: ( Conc :> es+ , Concurrent :> es+ , File :> es+ , Log :> es+ , Process :> es+ , Reader ProjectRoot :> es+ , Timeout :> es+ )+ => (GhciLoading -> Eff es ())+ -> TestTimeout+ -> RenderedCommand 'Test+ -> Eff es Test.Suite+runTestSuiteWithGHCi onProgress testTimeout cmd = do+ ProjectRoot projectRoot <- ask+ result <- trySync+ $ withGhciProcess+ def+ cmd+ projectRoot+ onProgress+ noReady+ \ghci _ ->+ case testTimeout of+ TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress+ TestTimeout secs ->+ let duration = fromIntegral secs :: Second+ in maybeToRight secs+ <$> timeout duration (execGhci ghci ":main" noProgress)+ case result of+ Left ex -> case fromException ex of+ Just (e :: GhciProcessError) -> case e of+ UnexpectedExit (MkUnexpectedExit marker msg) ->+ pure+ $ Test.SuiteErrored+ $ Test.SuiteError+ { message = "Test suite failed while waiting for marker (" <> marker <> "):\n" <> msg+ }+ StartupTimeout -> pure $ Test.SuiteErrored $ Test.SuiteError "Test suite timed out before it could finish"+ StartupFailed msg ->+ pure+ $ Test.SuiteErrored+ $ Test.SuiteError+ $ "Test suite failed on startup, before Tricorder could start running the test suite itself:\n" <> msg+ Nothing ->+ pure+ $ Test.SuiteErrored+ $ Test.SuiteError {message = show ex}+ Right (Left secs) -> do+ pure+ $ Test.SuiteErrored+ $ Test.SuiteError+ { message = "Test suite timed out after " <> show secs <> "s"+ }+ Right (Right mainLines) ->+ pure+ $ let output = T.unlines mainLines+ in case detectOutcome output of+ GhciCrashed msg ->+ Test.SuiteErrored $ Test.SuiteError {message = msg}+ outcome ->+ Test.SuiteCompleted+ $ Test.SuiteCompletion+ { passed = outcome == GhciPassed+ , output+ , testCases = parseHspecOutput output+ , duration = parseHspecDuration output+ }+ where+ noReady _ = pure ()+ noProgress _ = pure ()++ -- | Scripted interpreter for testing. -- -- Each call to 'runTestSuite' pops the next result from the pre-loaded list.@@ -177,7 +269,7 @@ runScripted results = reinterpret_ (evalState results)- (\(RunTestSuite _ _ _ _ _) -> popResult)+ (\(RunTestSuite _ _ _) -> popResult) where popResult :: Eff (State [Either SomeException Test.Suite] : es) Test.Suite popResult =
src/Tricorder/Session.hs view
@@ -2,6 +2,7 @@ ( Session (..) , loadSession , inputSession+ , show ) where @@ -11,35 +12,54 @@ import Atelier.Effects.Log (Log) import Data.Default (Default (..)) import Effectful.Reader.Static (Reader, ask)+import Text.Regex.TDFA.Pattern (showPattern)+import Prelude hiding (show) import Atelier.Effects.Log qualified as Log import Data.Text qualified as T+import Prelude qualified as P import Tricorder.Build.ByteSize (ByteSize) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.CabalFile (CabalFile)-import Tricorder.Session.Command (Command (..), resolveCommand)+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate+ ( CommandTemplate (..)+ , hasPlaceholder+ , targetPlaceholder+ ) import Tricorder.Session.Config (Config (..)) import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..)) import Tricorder.Session.Hooks (Hooks) import Tricorder.Session.IdleTimeout (IdleTimeout (..))+import Tricorder.Session.Repl (resolveRepl) import Tricorder.Session.ReplBuildDir (ReplBuildDir (..))-import Tricorder.Session.Target (Target, definesCustomPrelude, resolveTargets)-import Tricorder.Session.TestTarget (TestTarget, resolveTestTargets)+import Tricorder.Session.Stage.Build.Session (BuildSession (..))+import Tricorder.Session.Stage.Test.Config (TestConfig (..))+import Tricorder.Session.Stage.Test.Session (TestSession (..))+import Tricorder.Session.Target (definesCustomPrelude)+import Tricorder.Session.TestTarget (getTestTarget) import Tricorder.Session.TestTimeout (TestTimeout (..))-import Tricorder.Session.WatchDirs (WatchDirs (..), resolveWatchDirs)+import Tricorder.Session.Util (indent, showList)+import Tricorder.Session.WatchDirs (WatchDirs (..)) import Tricorder.Session.WatchExclusionPatterns ( WatchExclusionPatterns (..) , resolveWatchExclusionPatterns ) import Tricorder.Build.ByteSize qualified as ByteSize+import Tricorder.Session.Stage qualified as Stage+import Tricorder.Session.Stage.Build.Session qualified as BuildSession+import Tricorder.Session.Stage.Eval.Command qualified as EvalCommand+import Tricorder.Session.Stage.Eval.Session qualified as EvalSession+import Tricorder.Session.Stage.Test.Session qualified as TestSession+import Tricorder.Session.WatchDirs qualified as WatchDirs data Session = Session- { command :: Command- , targets :: [Target]- , testTargets :: [TestTarget]+ { buildSession :: BuildSession+ , testSession :: TestSession+ , evalSession :: CommandTemplate 'Stage.Eval , testMemoryLimit :: Maybe ByteSize , watchDirs :: WatchDirs , watchExclusionPatterns :: WatchExclusionPatterns@@ -55,9 +75,9 @@ instance Default Session where def = Session- { command = def- , targets = []- , testTargets = []+ { buildSession = def+ , testSession = def+ , evalSession = def , testMemoryLimit = Nothing , watchDirs = def , watchExclusionPatterns = def@@ -80,14 +100,16 @@ loadSession = do projectRoot <- ask @ProjectRoot loadedCfg <- input+ Log.debug "Got config file" projectFiles <- input+ Log.debug "Got project files" let cfgFile = extractConfig @"session" @Config loadedCfg- effectiveTargets = resolveTargets projectFiles cfgFile.targets- testTargets = resolveTestTargets cfgFile effectiveTargets- watchDirs = resolveWatchDirs projectRoot projectFiles cfgFile effectiveTargets hooks = fromMaybe def cfgFile.hooks + warnDeprecatedConfig cfgFile+ Log.debug "Warned about deprecated config stuff"+ testMemoryLimit <- case cfgFile.testMemoryLimit of Nothing -> pure Nothing Just limit -> case ByteSize.fromText limit of@@ -110,6 +132,13 @@ pure $ WatchExclusionPatterns [] Right pts -> pure pts + repl <- resolveRepl projectRoot+ buildSession <- BuildSession.resolve cfgFile repl projectFiles+ let testSession = TestSession.resolve repl buildSession.targets cfgFile+ evalSession = EvalCommand.resolve repl cfgFile+ effectiveTargets = buildSession.targets <> (getTestTarget <$> testSession.targets)+ watchDirs = WatchDirs.resolve projectRoot projectFiles cfgFile effectiveTargets+ when (not (null effectiveTargets) && all (definesCustomPrelude projectFiles) effectiveTargets) $ Log.warn "Every resolved target exposes a custom Prelude module. GHCi may \@@ -118,16 +147,27 @@ \not define its own Prelude, or set an explicit command in your \ \tricorder configuration." - command <- resolveCommand projectRoot cfgFile effectiveTargets testTargets+ warnMissingTargetPlaceholder "test" cfgFile.test.commandConfig.commandTemplate+ warnMissingTargetPlaceholder "eval" cfgFile.eval.commandTemplate + warnIgnoredextraAutoArguments+ "build"+ (cfgFile.build.commandTemplate <|> cfgFile.command)+ cfgFile.build.extraAutoArguments+ warnIgnoredextraAutoArguments+ "test"+ cfgFile.test.commandConfig.commandTemplate+ cfgFile.test.commandConfig.extraAutoArguments+ warnIgnoredextraAutoArguments "eval" cfgFile.eval.commandTemplate cfgFile.eval.extraAutoArguments+ pure $ Session- { targets = effectiveTargets- , command+ { buildSession+ , testSession+ , evalSession , watchDirs , watchExclusionPatterns , testMemoryLimit- , testTargets , replBuildDir = ReplBuildDir cfgFile.replBuildDir , testTimeout = TestTimeout cfgFile.testTimeout , generateWithHpack = GenerateWithHpack cfgFile.generateWithHpack@@ -145,3 +185,78 @@ ) => Eff (Input Session : es) a -> Eff es a inputSession = runInputEff loadSession+++-- | Warn once per session load for each deprecated top-level config key+-- still in use, regardless of whether its replacement also takes+-- precedence.+warnDeprecatedConfig :: (Log :> es) => Config -> Eff es ()+warnDeprecatedConfig cfgFile = do+ whenJust cfgFile.command+ $ const+ $ Log.warn "session.command is deprecated; use session.build.command_template instead."+ unless (null cfgFile.targets)+ $ Log.warn "session.targets is deprecated; use session.build.targets instead."+ whenJust cfgFile.testTargets+ $ const+ $ Log.warn "session.test_targets is deprecated; use session.test.targets instead."+++-- | Warn when a section's @extra_auto_arguments@ is set alongside a custom+-- @command_template@, since 'Tricorder.Session.Config.extraAutoArguments'+-- would otherwise be silently ignored.+warnIgnoredextraAutoArguments :: (Log :> es) => Text -> Maybe Text -> [Text] -> Eff es ()+warnIgnoredextraAutoArguments section customTemplate extraAutoArguments =+ when (isJust customTemplate && not (null extraAutoArguments))+ $ Log.warn+ $ "session."+ <> section+ <> ".command_template is set; session."+ <> section+ <> ".extra_auto_arguments is ignored (it only applies to Tricorder's \+ \automatically resolved command)."+++-- | Warn when a custom @test@\/@eval@ @command_template@ has no @{target}@+-- placeholder — without it, every invocation (one per test target or+-- module) silently runs the exact same command. @build@ isn't checked this+-- way: a missing @{targets}@ there mirrors past custom-@command@ behavior,+-- more likely deliberate than an oversight.+warnMissingTargetPlaceholder :: (Log :> es) => Text -> Maybe Text -> Eff es ()+warnMissingTargetPlaceholder section customTemplate =+ case customTemplate of+ Just tpl+ | not (hasPlaceholder targetPlaceholder tpl) ->+ Log.warn+ $ "session."+ <> section+ <> ".command_template has no {target} placeholder — every "+ <> section+ <> " invocation will run the same command."+ _ -> pure ()+++show :: Session -> Text+show session =+ T.intercalate+ "\n"+ [ "Loaded session"+ , "Build configuration:"+ , indent $ BuildSession.show session.buildSession+ , "Test configuration:"+ , indent $ TestSession.show session.testSession+ , "Eval comments configuration:"+ , indent $ EvalSession.show session.evalSession+ , "Watch dirs:"+ , indent $ showList toText session.watchDirs.getWatchDirs+ , "Watch exclusion patterns:"+ , indent+ $ showList+ (toText . showPattern . fst)+ session.watchExclusionPatterns.getWatchExclusionPatterns+ , "Repl build dir: " <> toText session.replBuildDir.getReplBuildDir+ , -- TODO: Remove " seconds" when TestTimeout is converted to a proper time unit.+ "Test timeout: " <> P.show session.testTimeout.getTestTimeout <> " seconds"+ , "Test memory limit: " <> P.show session.testMemoryLimit+ , "Generate with hpack: " <> P.show session.generateWithHpack.getGenerateWithHpack+ ]
src/Tricorder/Session/CabalFile.hs view
@@ -1,29 +1,37 @@ module Tricorder.Session.CabalFile ( CabalFile (..) , inputCabalFiles- , discoverCabalFiles+ , discoverPackages+ , discoverCabalPackages+ , discoverStackPackages+ , readProjectFile ) where import Atelier.Effects.Env (Env) import Atelier.Effects.FileSystem (FileSystem, doesFileExist, listDirectory, readFileBs) import Atelier.Effects.FileSystem.Glob (Glob, globDir1)-import Atelier.Effects.Input (Input, runInputEff)+import Atelier.Effects.Input (Input, input, runInputEff) import Atelier.Effects.Log (Log) import Data.Traversable (for) import Distribution.Fields (Field (..), FieldLine (..), Name (..), readFields) import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe) import Distribution.Types.GenericPackageDescription (GenericPackageDescription)+import Effectful.Exception (catch, throwIO) import Effectful.Reader.Static (Reader, ask) import System.FilePath (normalise, takeExtension, (</>)) import System.FilePath.Glob (compile)+import System.IO.Error (userError) import Atelier.Effects.Env qualified as Env+import Atelier.Effects.FileSystem qualified as FileSystem import Atelier.Effects.Log qualified as Log import Data.ByteString.Char8 qualified as BC+import Data.List qualified as List import Data.Text qualified as T import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.StackProject (StackProject (..)) data CabalFile = CabalFile@@ -37,42 +45,93 @@ :: ( Env :> es , FileSystem :> es , Glob :> es+ , Input StackProject :> es , Log :> es , Reader ProjectRoot :> es ) => Eff (Input [CabalFile] : es) a -> Eff es a-inputCabalFiles = runInputEff do- projectFilePaths <- discoverCabalFiles- (faileds, packageDescriptions) <-- partitionEithers <$> for projectFilePaths \p -> do- contents <- readFileBs p- case parseGenericPackageDescriptionMaybe contents of- Nothing -> pure $ Left p- Just gpd -> pure $ Right $ CabalFile p gpd- unless (null faileds) do- Log.warn- $ "Failed to parse .cabal files for the following packages: "- <> T.intercalate ", " (toText <$> faileds)- pure $ packageDescriptions+inputCabalFiles = runInputEff $ Log.withNamespace "CabalFile" do+ packageRes <- discoverPackages+ case packageRes of+ Left err -> throwIO $ userError $ toString err+ Right projectFilePaths -> do+ (faileds, packageDescriptions) <-+ logFailure+ $ partitionEithers <$> for projectFilePaths readProjectFile+ unless (null faileds) do+ Log.warn+ $ "Failed to parse .cabal files for the following packages: "+ <> T.intercalate ", " (toText <$> faileds)+ pure $ packageDescriptions+ where+ logFailure =+ ( `catch`+ \(e :: SomeException) -> do+ Log.err $ "Failed to read package descriptions"+ Log.err $ show e+ throwIO e+ ) +readProjectFile+ :: (FileSystem :> es)+ => FilePath -> Eff es (Either FilePath CabalFile)+readProjectFile projectFilePath = do+ fileExists <- FileSystem.doesFileExist projectFilePath+ if fileExists+ then readFile projectFilePath+ else do+ dirExists <- FileSystem.doesDirectoryExist projectFilePath+ if dirExists+ then do+ files <- FileSystem.listDirectory projectFilePath+ let mCabalFile = find (".cabal" `List.isSuffixOf`) files+ case mCabalFile of+ Nothing -> pure $ Left projectFilePath+ Just cabalFile -> readFile $ projectFilePath </> cabalFile+ else+ pure $ Left projectFilePath+ where+ readFile path = do+ contents <- readFileBs path+ case parseGenericPackageDescriptionMaybe contents of+ Nothing -> pure $ Left path+ Just gpd -> pure $ Right $ CabalFile path gpd+++discoverPackages+ :: ( Env :> es+ , FileSystem :> es+ , Glob :> es+ , Input StackProject :> es+ , Reader ProjectRoot :> es+ )+ => Eff es (Either Text [FilePath])+discoverPackages = do+ ProjectRoot projectRoot <- ask+ hasStackYaml <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"+ if hasStackYaml+ then discoverStackPackages+ else discoverCabalPackages++ -- | Discovers `.cabal` files in all locations and formats Cabal itself -- supports.-discoverCabalFiles+discoverCabalPackages :: ( Env :> es , FileSystem :> es , Glob :> es , Reader ProjectRoot :> es )- => Eff es [FilePath]-discoverCabalFiles = do+ => Eff es (Either Text [FilePath])+discoverCabalPackages = do ProjectRoot projectRoot <- ask homeCabalFiles <- maybe [] (one . (</> ".cabal/config")) <$> Env.lookupEnv "HOME" let projectFilePaths = projectCabalFiles projectRoot <> homeCabalFiles projectFiles <- filterM doesFileExist projectFilePaths if null projectFiles then- cabalFilesIn projectRoot+ Right <$> cabalFilesIn projectRoot else do packages <- fmap (find (not . null)) $ for projectFiles \projectFile -> do@@ -82,8 +141,8 @@ (cabalFilesForEntry projectRoot) (projectPackageEntries contents) case packages of- Nothing -> cabalFilesIn projectRoot- Just pkgs -> pure pkgs+ Nothing -> Right <$> cabalFilesIn projectRoot+ Just pkgs -> pure $ Right pkgs where projectCabalFiles projectRoot = (projectRoot </>) <$> ["cabal.project.local", "cabal.project.freeze", "cabal.project"]@@ -98,6 +157,15 @@ resolveMatch path | isCabalFile path = pure [path] | otherwise = cabalFilesIn path+++discoverStackPackages+ :: (Input StackProject :> es, Reader ProjectRoot :> es)+ => Eff es (Either Text [FilePath])+discoverStackPackages = do+ ProjectRoot projectRoot <- ask+ project <- input+ pure $ Right $ normalise . (projectRoot </>) <$> project.packages -- | List the @.cabal@ files directly inside a directory.
− src/Tricorder/Session/Command.hs
@@ -1,141 +0,0 @@-module Tricorder.Session.Command- ( Command (..)- , Repl (..)- , render- , resolveCommand- )-where--import Atelier.Effects.FileSystem (FileSystem)-import Data.Default (Default (..))-import Effectful.NonDet (NonDet, OnEmptyPolicy (..), emptyEff, plusEff, runNonDet)-import System.FilePath ((</>))--import Atelier.Effects.FileSystem qualified as FileSystem-import Data.List qualified as List--import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Config (Config, command, replBuildDir)-import Tricorder.Session.Target (Target (..))-import Tricorder.Session.TestTarget (TestTarget, getTestTarget)--import Tricorder.Session.Target qualified as Target---data Command = Command- { repl :: Repl- , arguments :: [Text]- , targets :: [Target]- }- deriving stock (Eq, Generic, Show)---data Repl = StackMulti | Stack | Cabal | Unknown- deriving stock (Eq, Generic, Show)---render :: Command -> Text-render command = unwords $ renderRepl command.repl <> command.arguments <> tgts- where- tgts = case command.repl of- Stack -> List.nub $ Target.componentName <$> command.targets- StackMulti -> List.nub $ Target.renderTarget <$> command.targets- Cabal -> Target.renderTarget <$> command.targets- Unknown -> Target.renderTarget <$> command.targets---renderRepl :: Repl -> [Text]-renderRepl StackMulti = ["stack", "ghci"]-renderRepl Stack = ["stack", "ghci"]-renderRepl Cabal = ["cabal", "repl"]-renderRepl Unknown = []---instance Default Command where- def = Command Unknown [] []----- | Resolve the GHCi command, using config if set or autodetecting otherwise.------ The @testTargets@ are the discovered @test:@ components; they are appended to--- the auto-detected @all@ target (see 'detectCommand'). They are ignored when--- the user has pinned an explicit @command@ or explicit @targets@ in config.-resolveCommand- :: (FileSystem :> es) => ProjectRoot -> Config -> [Target] -> [TestTarget] -> Eff es Command-resolveCommand projectRoot@(ProjectRoot root) cfg targets testTargets =- case cfg.command of- Just cmd -> case words cmd of- "stack" : "repl" : args -> detectStackKind args- "stack" : "ghci" : args -> detectStackKind args- "cabal" : "repl" : args -> pure $ Command Cabal args []- args -> pure $ Command Unknown args []- Nothing ->- detectCommand targets testTargets cfg.replBuildDir projectRoot- where- detectStackKind args = do- hasCabalFileInRoot <- any (".cabal" `List.isSuffixOf`) <$> FileSystem.listDirectory root- let repl =- if hasCabalFileInRoot- then- Stack- else- StackMulti- pure $ Command repl args []---detectCommand- :: (FileSystem :> es) => [Target] -> [TestTarget] -> FilePath -> ProjectRoot -> Eff es Command-detectCommand targets testTargets replBuildDir projectRoot = do- cmd <-- fmap (fromMaybe (fallback replBuildDir) . rightToMaybe)- $ runNonDet OnEmptyKeep- $ useStack projectRoot- `plusEff` useMultiCabal projectRoot replBuildDir- pure- $ cmd- { targets =- if not (null targets)- then- targets- else- Bare "all" : (getTestTarget <$> testTargets)- }---useStack :: (FileSystem :> es, NonDet :> es) => ProjectRoot -> Eff es Command-useStack (ProjectRoot projectRoot) = do- hasStack <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"- if hasStack- then- pure $ Command Stack [] []- else- emptyEff---useMultiCabal :: (FileSystem :> es, NonDet :> es) => ProjectRoot -> FilePath -> Eff es Command-useMultiCabal (ProjectRoot projectRoot) replBuildDir = do- hasCabalProject <- FileSystem.doesFileExist $ projectRoot </> "cabal.project"- hasCabalFiles <- any (".cabal" `List.isSuffixOf`) <$> FileSystem.listDirectory projectRoot- if hasCabalFiles || hasCabalProject- then- pure- $ Command- { repl = Cabal- , arguments = ["--enable-multi-repl"] <> buildDirFlag replBuildDir- , targets = []- }- else- emptyEff---fallback :: FilePath -> Command-fallback replBuildDir =- Command- { repl = Cabal- , arguments = buildDirFlag replBuildDir- , targets = [Bare "all"]- }---buildDirFlag :: FilePath -> [Text]-buildDirFlag replBuildDir = ["--builddir", toText replBuildDir]
+ src/Tricorder/Session/Command/RenderedCommand.hs view
@@ -0,0 +1,9 @@+module Tricorder.Session.Command.RenderedCommand (RenderedCommand (..)) where++import Tricorder.Session.Stage (Stage (..))+++-- | A fully rendered, ready-to-spawn shell command, tagged with the 'Stage'+-- it was rendered for.+newtype RenderedCommand (stage :: Stage) = RenderedCommand {getRenderedCommand :: Text}+ deriving (Eq, Show) via Text
+ src/Tricorder/Session/CommandConfig.hs view
@@ -0,0 +1,37 @@+module Tricorder.Session.CommandConfig (CommandConfig (..)) where++import Atelier.Types.QuietSnake (QuietSnake (..))+import Data.Aeson (FromJSON, ToJSON)+import Data.Default (Default (..))++import Tricorder.Session.Stage (Stage)+++-- | Configuration shared by the @build@, @test@, and @eval@ sections: an+-- optional command template (falls back to Tricorder's automatically+-- resolved command per detected REPL kind when unset), an optional explicit+-- target list, and extra CLI arguments appended to that automatically+-- resolved command.+data CommandConfig (stage :: Stage) = CommandConfig+ { commandTemplate :: Maybe Text+ -- ^ User-provided template string to use for the shell command for the+ -- given stage. Takes priority over 'extraAutoArguments'. The exact+ -- template variable name depends on the stage the 'CommandConfig' is for.+ , targets :: Maybe [Text]+ -- ^ List of targets to run the stage against.+ , extraAutoArguments :: [Text]+ -- ^ Arguments to pass to the Tricorder-detected command for the given+ -- stage. Mutually exclusive with 'commandTemplate'. If both+ -- 'commandTemplate' and 'extraAutoArguments' are set, a warning is logged.+ }+ deriving stock (Eq, Generic, Show)+ deriving (FromJSON, ToJSON) via QuietSnake (CommandConfig stage)+++instance Default (CommandConfig stage) where+ def =+ CommandConfig+ { commandTemplate = Nothing+ , targets = Nothing+ , extraAutoArguments = []+ }
+ src/Tricorder/Session/CommandTemplate.hs view
@@ -0,0 +1,123 @@+module Tricorder.Session.CommandTemplate+ ( CommandTemplate (..)+ , renderText+ , renderTargetsFor+ , hasPlaceholder+ , targetsPlaceholder+ , targetPlaceholder+ , show+ )+where++import Data.Default (Default (..))+import Prelude hiding (show)++import Data.List qualified as List+import Data.Text qualified as T+import Prelude qualified as P++import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Target (Target (..))+import Tricorder.Session.Util (indent, showList)++import Tricorder.Session.Target qualified as Target+++-- | A command string to be rendered with a provided list of targets.+data CommandTemplate (stage :: Stage) = CommandTemplate+ { repl :: Repl+ , template :: Text+ , arguments :: [Text]+ , placeholder :: Text+ -- ^ The bare placeholder name (without braces) 'template' may contain —+ -- see 'targetsPlaceholder' and 'targetPlaceholder'.+ }+ deriving stock (Eq, Generic, Show)+++-- | The @{targets}@ placeholder, used by @build@: one invocation covers+-- every target.+targetsPlaceholder :: Text+targetsPlaceholder = "targets"+++-- | The @{target}@ placeholder, used by @test@ and @eval@: one invocation+-- per target.+targetPlaceholder :: Text+targetPlaceholder = "target"+++instance Default (CommandTemplate 'Build) where+ def = CommandTemplate Unknown ("cabal repl {" <> targetsPlaceholder <> "}") [] targetsPlaceholder+++instance Default (CommandTemplate 'Test) where+ def = CommandTemplate Unknown ("cabal repl {" <> targetPlaceholder <> "}") [] targetPlaceholder+++instance Default (CommandTemplate 'Eval) where+ def = CommandTemplate Unknown ("cabal repl {" <> targetPlaceholder <> "}") [] targetPlaceholder+++-- | Substitute 'placeholder' in 'template' with the REPL-rendered target(s),+-- then append 'arguments'. Not exported — each phase renders differently (a+-- memory-limit flag for test), so use 'renderBuild'\/'renderTest'\/'renderEval'+-- instead, which return the phase-tagged+-- 'Tricorder.Session.Command.RenderedCommand.RenderedCommand'.+renderText :: CommandTemplate stage -> [Target] -> Text+renderText commandTemplate targets =+ T.unwords $ substituted <> commandTemplate.arguments+ where+ substituted =+ T.words+ $ substitutePlaceholder+ commandTemplate.placeholder+ (renderTargetsFor commandTemplate.repl targets)+ commandTemplate.template+++-- | Render targets the way each REPL kind expects on the command line.+-- Plain (single-package) @stack ghci@ only understands bare component+-- names, and needs deduplication since multiple targets can share one;+-- every other kind takes the fully qualified @[package:]kind:name@ form.+renderTargetsFor :: Repl -> [Target] -> [Text]+renderTargetsFor = \case+ Stack -> List.nub . fmap Target.componentName+ StackMulti -> List.nub . fmap Target.render+ Cabal -> fmap Target.render+ Unknown -> fmap Target.render+++-- | Substitute every unescaped @{<placeholderName>}@ in a template with the+-- (already REPL-rendered) target list, space-joined. @\\{<placeholderName>}@+-- escapes to a literal @{<placeholderName>}@, with no substitution.+substitutePlaceholder :: Text -> [Text] -> Text -> Text+substitutePlaceholder placeholderName renderedTargets =+ T.replace escapeSentinel bareholder+ . T.replace bareholder (T.unwords renderedTargets)+ . T.replace ("\\" <> bareholder) escapeSentinel+ where+ bareholder = "{" <> placeholderName <> "}"+ -- Must not itself contain the literal substring "{<placeholderName>}" —+ -- the unescaped-placeholder pass above would otherwise match inside it.+ escapeSentinel = "\NUL__escaped_" <> placeholderName <> "_placeholder__\NUL"+++-- | Whether a template contains @{<placeholderName>}@, escaped or not. Used+-- to warn when a @test@\/@eval@ @command_template@ omits it (see+-- 'Tricorder.Session.loadSession').+hasPlaceholder :: Text -> Text -> Bool+hasPlaceholder placeholderName template = ("{" <> placeholderName <> "}") `T.isInfixOf` template+++show :: CommandTemplate stage -> Text+show tmpl =+ T.intercalate+ "\n"+ [ "Template: " <> tmpl.template+ , "Repl: " <> P.show tmpl.repl+ , "Template placeholder: " <> P.show tmpl.placeholder+ , "Arguments:"+ , indent $ showList id tmpl.arguments+ ]
src/Tricorder/Session/Config.hs view
@@ -5,21 +5,34 @@ import Data.Aeson (FromJSON (..)) import Data.Default (Default (..)) +import Tricorder.Session.CommandConfig (CommandConfig) import Tricorder.Session.Hooks (Hooks)+import Tricorder.Session.Stage.Test.Config (TestConfig) +import Tricorder.Session.Stage qualified as Stage + data Config = Config- { command :: Maybe Text- , targets :: [Text]+ { build :: CommandConfig 'Stage.Build+ , test :: TestConfig+ , eval :: CommandConfig 'Stage.Eval , watchDirs :: [FilePath] , watchExclusionPatterns :: [Text]- , testTargets :: Maybe [Text] , replBuildDir :: FilePath , testTimeout :: Int , generateWithHpack :: Bool , testMemoryLimit :: Maybe Text , hooks :: Maybe Hooks , idleTimeoutSeconds :: Int+ , command :: Maybe Text+ -- ^ DEPRECATED: use 'build'.'commandTemplate' instead.+ -- TODO: Remove at or after version 0.8.0.0.+ , targets :: [Text]+ -- ^ DEPRECATED: use 'build'.'targets' instead.+ -- TODO: Remove at or after version 0.8.0.0.+ , testTargets :: Maybe [Text]+ -- ^ DEPRECATED: use 'test'.'targets' instead.+ -- TODO: Remove at or after version 0.8.0.0. } deriving stock (Eq, Generic, Show) deriving (FromJSON) via WithDefaults (QuietSnake Config)@@ -28,15 +41,18 @@ instance Default Config where def = Config- { command = Nothing- , targets = []+ { build = def+ , test = def+ , eval = def , watchDirs = [] , watchExclusionPatterns = []- , testTargets = Nothing , replBuildDir = "dist-newstyle/tricorder" , testTimeout = 10 , generateWithHpack = True , testMemoryLimit = Nothing , hooks = Nothing , idleTimeoutSeconds = 300+ , command = Nothing+ , targets = []+ , testTargets = Nothing }
+ src/Tricorder/Session/Repl.hs view
@@ -0,0 +1,45 @@+module Tricorder.Session.Repl+ ( Repl (..)+ , resolveRepl+ )+where++import Atelier.Effects.FileSystem (FileSystem)+import Data.Yaml (decodeEither')+import Effectful.Exception (throwIO)+import System.FilePath ((</>))+import System.IO.Error (userError)++import Atelier.Effects.FileSystem qualified as FileSystem+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM++import Tricorder.Runtime (ProjectRoot (..))+++data Repl = StackMulti | Stack | Cabal | Unknown+ deriving stock (Eq, Generic, Show)+++-- | Resolve the project's REPL kind from the filesystem, independent of any+-- @build@\/@test@\/@eval@ command template — this decides both the+-- automatically resolved templates and how targets are rendered for all+-- three phases.+resolveRepl :: (FileSystem :> es) => ProjectRoot -> Eff es Repl+resolveRepl pr@(ProjectRoot projectRoot) = do+ hasStack <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"+ if hasStack+ then stackReplKind pr+ else pure Cabal+++stackReplKind :: (FileSystem :> es) => ProjectRoot -> Eff es Repl+stackReplKind (ProjectRoot projectRoot) = do+ stackYaml <- FileSystem.readFileBs $ projectRoot </> "stack.yaml"+ case decodeEither' stackYaml of+ Left err -> throwIO $ userError $ "Could not read and decode stack.yaml: " <> show err+ Right value -> pure $ case value of+ Aeson.Object km -> case KM.lookup "packages" km of+ Just (Aeson.Array arr) | length arr > 1 -> StackMulti+ _ -> Stack+ _ -> Stack
+ src/Tricorder/Session/StackProject.hs view
@@ -0,0 +1,70 @@+module Tricorder.Session.StackProject+ ( StackProject (..)+ , inputWithCache+ )+where++import Atelier.Effects.FileSystem (FileSystem)+import Atelier.Effects.Input (Input, runInputEff)+import Data.Aeson (FromJSON, ToJSON)+import Data.Time (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Effectful.Concurrent.MVar.Strict (Concurrent)+import Effectful.Concurrent.STM+ ( TVar+ , atomically+ , newTVarIO+ , readTVar+ , writeTVar+ )+import Effectful.Exception (throwIO)+import Effectful.Reader.Static (Reader, ask)+import GHC.Generics (Generically (..))+import System.FilePath ((</>))+import System.IO.Error (userError)++import Atelier.Effects.FileSystem qualified as FileSystem+import Data.Yaml qualified as Yaml++import Tricorder.Runtime (ProjectRoot (..))+++newtype StackProject = StackProject+ { packages :: [FilePath]+ }+ deriving stock (Eq, Generic, Show)+ deriving (FromJSON, ToJSON) via Generically StackProject+++inputWithCache+ :: ( Concurrent :> es+ , FileSystem :> es+ , Reader ProjectRoot :> es+ )+ => Eff (Input StackProject : es) a -> Eff es a+inputWithCache act = do+ cache :: TVar StackProject <- newTVarIO $ StackProject []+ lastModified :: TVar UTCTime <- newTVarIO $ posixSecondsToUTCTime 0+ flip runInputEff act do+ ProjectRoot projectRoot <- ask+ let stackYamlPath = projectRoot </> "stack.yaml"+ exists <- FileSystem.doesFileExist stackYamlPath+ if not exists+ then throwIO $ userError "stack.yaml does not exist"+ else do+ newLastMod <- FileSystem.getModificationTime stackYamlPath+ oldLastMod <- atomically $ readTVar lastModified+ if oldLastMod /= newLastMod+ then do+ contents <- FileSystem.readFileBs stackYamlPath+ let result = Yaml.decodeEither' contents+ case result of+ Right x -> do+ atomically do+ writeTVar cache x+ writeTVar lastModified newLastMod+ pure x+ Left err ->+ throwIO $ userError $ "Could not parse stack.yaml: " <> show err+ else+ atomically $ readTVar cache
+ src/Tricorder/Session/Stage.hs view
@@ -0,0 +1,4 @@+module Tricorder.Session.Stage (Stage (..)) where+++data Stage = Build | Test | Eval
+ src/Tricorder/Session/Stage/Build/Command.hs view
@@ -0,0 +1,78 @@+module Tricorder.Session.Stage.Build.Command+ ( render+ , resolve+ )+where++import Atelier.Effects.FileSystem (FileSystem)+import System.FilePath ((</>))++import Atelier.Effects.FileSystem qualified as FileSystem+import Data.List qualified as List++import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..), renderText, targetsPlaceholder)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Target (Target (..))+++-- | Render the @build@ command: every target goes into the one invocation+-- that covers all of them.+render :: CommandTemplate 'Build -> [Target] -> RenderedCommand 'Build+render commandTemplate targets = RenderedCommand $ renderText commandTemplate targets+++resolve+ :: (FileSystem :> es)+ => ProjectRoot+ -> Config+ -> Repl+ -> [Target]+ -> Eff es (CommandTemplate 'Build, [Target])+resolve projectRoot cfg repl effectiveTargets = do+ template <- case customTemplate of+ Just tpl -> pure tpl+ Nothing -> defaultBuildTemplate projectRoot repl cfg.replBuildDir+ pure+ ( CommandTemplate+ { repl+ , template+ , arguments = maybe cfg.build.extraAutoArguments (const []) customTemplate+ , placeholder = targetsPlaceholder+ }+ , if not (null effectiveTargets)+ then effectiveTargets+ else [Bare "all"]+ )+ where+ customTemplate = cfg.build.commandTemplate <|> cfg.command+++-- | Whether the project has a @cabal.project@ or any @*.cabal@ file. Used+-- only to pick the automatically resolved template's flags — it has no+-- bearing on 'Repl', which is 'Cabal' either way.+isCabalProject :: (FileSystem :> es) => ProjectRoot -> Eff es Bool+isCabalProject (ProjectRoot projectRoot) = do+ hasCabalProject <- FileSystem.doesFileExist $ projectRoot </> "cabal.project"+ hasCabalFiles <- any (".cabal" `List.isSuffixOf`) <$> FileSystem.listDirectory projectRoot+ pure $ hasCabalProject || hasCabalFiles+++-- | Tricorder's automatically resolved @build@ template for a resolved+-- 'Repl'. Cabal projects additionally probe the filesystem to decide whether+-- @--enable-multi-repl@ applies.+defaultBuildTemplate :: (FileSystem :> es) => ProjectRoot -> Repl -> FilePath -> Eff es Text+defaultBuildTemplate projectRoot repl replBuildDir = case repl of+ Stack -> pure "stack ghci {targets}"+ StackMulti -> pure "stack ghci {targets}"+ Unknown -> pure "cabal repl {targets}"+ Cabal -> do+ isProject <- isCabalProject projectRoot+ pure+ $ if isProject+ then "cabal repl --enable-multi-repl --builddir " <> toText replBuildDir <> " {targets}"+ else "cabal repl --builddir " <> toText replBuildDir <> " {targets}"
+ src/Tricorder/Session/Stage/Build/Session.hs view
@@ -0,0 +1,66 @@+module Tricorder.Session.Stage.Build.Session+ ( BuildSession (..)+ , resolve+ , show+ )+where++import Atelier.Effects.FileSystem (FileSystem)+import Data.Default (Default (..))+import Effectful.Reader.Static (Reader)+import Prelude hiding (show)++import Data.Text qualified as T+import Effectful.Reader.Static qualified as Reader++import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.CabalFile (CabalFile)+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl)+import Tricorder.Session.Target (Target)+import Tricorder.Session.Util (indent, showList)++import Tricorder.Session.CommandTemplate qualified as CommandTemplate+import Tricorder.Session.Stage qualified as Stage+import Tricorder.Session.Stage.Build.Command qualified as BuildCommand+import Tricorder.Session.Target qualified as Target+++data BuildSession = BuildSession+ { commandTemplate :: CommandTemplate 'Stage.Build+ , targets :: [Target]+ }+ deriving stock (Eq)+++instance Default BuildSession where+ def = BuildSession def []+++resolve+ :: (FileSystem :> es, Reader ProjectRoot :> es)+ => Config -> Repl -> [CabalFile] -> Eff es BuildSession+resolve config repl projectFiles = do+ projectRoot <- Reader.ask+ (commandTemplate, targets) <- BuildCommand.resolve projectRoot config repl effectiveTargets+ pure+ $ BuildSession+ { commandTemplate+ , targets+ }+ where+ rawBuildTargets = fromMaybe config.targets config.build.targets+ effectiveTargets = Target.resolve projectFiles rawBuildTargets+++show :: BuildSession -> Text+show cfg =+ T.intercalate+ "\n"+ [ "Command template:"+ , indent $ CommandTemplate.show cfg.commandTemplate+ , "Targets:"+ , indent $ showList Target.render cfg.targets+ ]
+ src/Tricorder/Session/Stage/Eval/Command.hs view
@@ -0,0 +1,34 @@+module Tricorder.Session.Stage.Eval.Command+ ( render+ , resolve+ )+where++import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..), renderText, targetPlaceholder)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl)+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Stage.Test.Session (defaultTestTemplate)+import Tricorder.Session.Target (Target)+++-- | Render the @eval@ command for a single source file's short-lived+-- session: the module being evaluated is substituted as the one target.+render :: CommandTemplate 'Eval -> [Target] -> RenderedCommand 'Eval+render commandTemplate targets = RenderedCommand $ renderText commandTemplate targets+++resolve :: Repl -> Config -> CommandTemplate 'Eval+resolve repl cfg =+ CommandTemplate+ { repl+ , template = fromMaybe (defaultEvalTemplate repl) cfg.eval.commandTemplate+ , arguments = maybe cfg.eval.extraAutoArguments (const []) cfg.eval.commandTemplate+ , placeholder = targetPlaceholder+ }+++defaultEvalTemplate :: Repl -> Text+defaultEvalTemplate = defaultTestTemplate
+ src/Tricorder/Session/Stage/Eval/Session.hs view
@@ -0,0 +1,27 @@+module Tricorder.Session.Stage.Eval.Session+ ( EvalSession+ , show+ )+where++import Prelude hiding (show)++import Data.Text qualified as T++import Tricorder.Session.CommandTemplate (CommandTemplate)+import Tricorder.Session.Util (indent)++import Tricorder.Session.CommandTemplate qualified as CommandTemplate+import Tricorder.Session.Stage qualified as Stage+++type EvalSession = CommandTemplate 'Stage.Eval+++show :: EvalSession -> Text+show cfg =+ T.intercalate+ "\n"+ [ "Command template:"+ , indent $ CommandTemplate.show cfg+ ]
+ src/Tricorder/Session/Stage/Test/Command.hs view
@@ -0,0 +1,60 @@+module Tricorder.Session.Stage.Test.Command+ ( RenderedTestCommand (..)+ , render+ )+where++import Tricorder.Build.ByteSize (ByteSize)+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..))+import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Stage.Test.Session (ResolvedTestOptions (..), TestSession (..))+import Tricorder.Session.TestTarget (TestTarget (..))++import Tricorder.Build.ByteSize qualified as ByteSize+import Tricorder.Session.CommandTemplate qualified as CommandTemplate+++data RenderedTestCommand = RenderedTestCommand+ { command :: RenderedCommand 'Test+ , options :: ResolvedTestOptions+ }+++render :: TestSession -> Maybe ByteSize -> TestTarget -> RenderedTestCommand+render testSession mMemoryLimit target =+ RenderedTestCommand+ { command =+ RenderedCommand+ $ CommandTemplate.renderText+ testSession.commandTemplate {arguments = testSession.commandTemplate.arguments <> memoryLimitArg}+ [getTestTarget target]+ , options = testSession.options+ }+ where+ memoryLimitArg =+ maybe+ []+ ( \limit ->+ let+ stack =+ [ "--ghc-options"+ , "+RTS -M"+ <> ByteSize.toRTSSize limit+ <> " -RTS"+ ]+ cabal =+ [ "--repl-options"+ , "+RTS -M"+ <> ByteSize.toRTSSize limit+ <> " -RTS"+ ]+ in+ case testSession.commandTemplate.repl of+ Stack -> stack+ StackMulti -> stack+ Cabal -> cabal+ Unknown -> cabal+ )+ mMemoryLimit
+ src/Tricorder/Session/Stage/Test/Config.hs view
@@ -0,0 +1,60 @@+module Tricorder.Session.Stage.Test.Config+ ( TestConfig (..)+ , Options (..)+ , OutputMode (..)+ )+where++import Atelier.Types.QuietSnake (QuietSnake (..))+import Data.Aeson (FromJSON (..), ToJSON, Value (..))+import Data.Aeson.Types (ToJSON (..))+import Data.Default (Default (..))+import GHC.Generics (Generically (..))++import Tricorder.Session.CommandConfig (CommandConfig)++import Tricorder.Session.Stage qualified as Stage+++data TestConfig = TestConfig+ { commandConfig :: CommandConfig 'Stage.Test+ , options :: Options+ }+ deriving stock (Eq, Generic, Show)+++instance Default TestConfig where+ def =+ TestConfig+ { commandConfig = def+ , options = def+ }+++instance ToJSON TestConfig where+ toJSON cfg = case (toJSON cfg.commandConfig, toJSON cfg.options) of+ (Object a, Object b) -> Object (a <> b)+ (a, _) -> a+++instance FromJSON TestConfig where+ parseJSON v = TestConfig <$> parseJSON v <*> parseJSON v+++newtype Options = Options+ { outputMode :: Maybe OutputMode+ }+ deriving stock (Eq, Generic, Show)+ deriving (FromJSON, ToJSON) via QuietSnake Options+++instance Default Options where+ def =+ Options+ { outputMode = Nothing+ }+++data OutputMode = ReplOutput | StdoutOutput+ deriving stock (Eq, Generic, Show)+ deriving (FromJSON, ToJSON) via Generically OutputMode
+ src/Tricorder/Session/Stage/Test/Session.hs view
@@ -0,0 +1,111 @@+module Tricorder.Session.Stage.Test.Session+ ( TestSession (..)+ , ResolvedTestOptions (..)+ , resolve+ , defaultTestTemplate+ , show+ )+where++import Data.Default (Default (..))+import Prelude hiding (show)++import Data.Text qualified as T+import Prelude qualified as P++import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..), targetPlaceholder)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Stage.Test.Config (Options (..), OutputMode (..), TestConfig (..))+import Tricorder.Session.Target (Target)+import Tricorder.Session.TestTarget (TestTarget (..))+import Tricorder.Session.Util (indent, showList)++import Tricorder.Session.CommandTemplate qualified as CommandTemplate+import Tricorder.Session.Stage qualified as Stage+import Tricorder.Session.TestTarget qualified as TestTarget+++data TestSession = TestSession+ { commandTemplate :: CommandTemplate 'Stage.Test+ , targets :: [TestTarget]+ , options :: ResolvedTestOptions+ }+ deriving stock (Eq)+++instance Default TestSession where+ def =+ TestSession+ { commandTemplate = def+ , targets = []+ , options = def+ }+++data ResolvedTestOptions = ResolvedTestOptions+ { outputMode :: OutputMode+ }+ deriving stock (Eq)+++instance Default ResolvedTestOptions where+ def = ResolvedTestOptions ReplOutput+++resolve :: Repl -> [Target] -> Config -> TestSession+resolve repl buildTargets cfg =+ TestSession+ { commandTemplate =+ CommandTemplate+ { repl+ , template+ , arguments =+ maybe+ cfg.test.commandConfig.extraAutoArguments+ (const [])+ cfg.test.commandConfig.commandTemplate+ , placeholder = targetPlaceholder+ }+ , targets = TestTarget.resolve cfg buildTargets+ , options =+ ResolvedTestOptions+ { outputMode = fromMaybe detectedOutputMode cfg.test.options.outputMode+ }+ }+ where+ template = fromMaybe (defaultTestTemplate repl) cfg.test.commandConfig.commandTemplate+ detectedOutputMode+ | "stack repl" `T.isPrefixOf` template+ || "stack ghci" `T.isPrefixOf` template+ || "cabal repl" `T.isPrefixOf` template =+ ReplOutput+ | otherwise = StdoutOutput+++defaultTestTemplate :: Repl -> Text+defaultTestTemplate = \case+ Stack -> "stack ghci {target}"+ StackMulti -> "stack ghci {target}"+ Cabal -> "cabal repl {target}"+ Unknown -> "cabal repl {target}"+++show :: TestSession -> Text+show cfg =+ T.intercalate+ "\n"+ [ "Command template:"+ , indent $ CommandTemplate.show cfg.commandTemplate+ , "Test targets:"+ , indent $ showList TestTarget.render cfg.targets+ , "Options:"+ , indent $ showTestOptions cfg.options+ ]+ where+ showTestOptions opts =+ T.intercalate+ "\n"+ [ "Output mode: " <> P.show opts.outputMode+ ]
src/Tricorder/Session/Target.hs view
@@ -1,12 +1,12 @@ module Tricorder.Session.Target ( Target (..) , ComponentKind (..)- , parseTarget- , renderTarget+ , parse+ , render+ , resolve+ , compare , componentName- , resolveTargets , definesCustomPrelude- , compareTargets , allComponentTargets ) where@@ -29,9 +29,11 @@ import Distribution.Types.PackageId (pkgName) import Distribution.Types.PackageName (unPackageName) import Distribution.Types.UnqualComponentName (mkUnqualComponentName, unUnqualComponentName)+import Prelude hiding (compare) import Data.Text qualified as T import Distribution.Types.BuildInfo.Lens qualified as Lens+import Prelude qualified as P import Tricorder.Session.CabalFile (CabalFile (..)) @@ -55,11 +57,11 @@ instance ToJSON Target where- toJSON = toJSON . renderTarget+ toJSON = toJSON . render instance FromJSON Target where- parseJSON = fmap parseTarget . parseJSON+ parseJSON = fmap parse . parseJSON instance ToJSONKey Target@@ -93,29 +95,29 @@ Bench -> "bench" --- | Parse a kind prefix, derived as the inverse of 'kindPrefix' so the two--- never drift apart [ref:kind_prefix_sole_source].-parseKind :: Text -> Maybe ComponentKind-parseKind = inverseMap kindPrefix-- -- | Classify a target's textual form. The grammar is @[kind:]name@ where -- @kind@ is one of @lib@, @flib@, @exe@, @test@, or @bench@; anything else (an -- unknown kind, a cabal alias such as @executable@, or extra colons) is -- 'Unrecognized'.-parseTarget :: Text -> Target-parseTarget target = case T.splitOn ":" target of+parse :: Text -> Target+parse target = case T.splitOn ":" target of [packageName, prefix, name] | Just kind <- parseKind prefix -> PackageQualified packageName kind name [prefix, name] | Just kind <- parseKind prefix -> Qualified kind name [name] -> Bare name _ -> Unrecognized target +-- | Parse a kind prefix, derived as the inverse of 'kindPrefix' so the two+-- never drift apart [ref:kind_prefix_sole_source].+parseKind :: Text -> Maybe ComponentKind+parseKind = inverseMap kindPrefix++ -- | Render a 'Target' back to the textual form cabal understands. Inverse of -- 'parseTarget' (lossless: @parseTarget . renderTarget == id@). Builds prefixes -- via 'kindPrefix' rather than hardcoding them [ref:kind_prefix_sole_source].-renderTarget :: Target -> Text-renderTarget = \case+render :: Target -> Text+render = \case Qualified kind name -> kindPrefix kind <> ":" <> name PackageQualified packageName kind name -> packageName <> ":" <> kindPrefix kind <> ":" <> name Bare name -> name@@ -134,14 +136,14 @@ -- raw target strings (from config) are parsed into structured 'Target's: the -- configured targets are parsed as-is, or all components across every -- discovered package are auto-detected when no targets are configured. Either--- way the result is sorted with 'compareTargets' so libraries exposing a custom+-- way the result is sorted with 'compare' so libraries exposing a custom -- @Prelude@ come last [ref:lib_sort_order].-resolveTargets :: [CabalFile] -> [Text] -> [Target]-resolveTargets cabalFiles = \case- targets@(_ : _) -> sortTargets $ parseTarget <$> targets+resolve :: [CabalFile] -> [Text] -> [Target]+resolve cabalFiles = \case+ targets@(_ : _) -> sortTargets $ parse <$> targets [] -> sortTargets $ foldMap (allComponentTargets . (.projectPackageDescription)) cabalFiles where- sortTargets = sortBy (compareTargets (definesCustomPrelude cabalFiles))+ sortTargets = sortBy (compare (definesCustomPrelude cabalFiles)) -- | [tag:lib_sort_order] When running @cabal repl <package defining custom@@ -154,16 +156,16 @@ -- 'definesCustomPrelude': only those that expose a @Prelude@ module are sorted -- last. This is more precise than sorting every @lib:@ target last — only the -- libraries that actually cause the failure are reordered.-compareTargets :: (Target -> Bool) -> Target -> Target -> Ordering-compareTargets definesPrelude a b+compare :: (Target -> Bool) -> Target -> Target -> Ordering+compare definesPrelude a b | definesPrelude a && not (definesPrelude b) = GT | not (definesPrelude a) && definesPrelude b = LT- | otherwise = compare (renderTarget a) (renderTarget b)+ | otherwise = P.compare (render a) (render b) -- | Check whether any of the discovered packages' libraries expose a @Prelude@ -- module for the given target. Used to build the predicate passed to--- 'compareTargets' so that only the libraries that actually cause the GHCi+-- 'compare' so that only the libraries that actually cause the GHCi -- startup failure are sorted last [ref:lib_sort_order]. definesCustomPrelude :: [CabalFile] -> Target -> Bool definesCustomPrelude cabalFiles target = any check cabalFiles
src/Tricorder/Session/TestTarget.hs view
@@ -1,50 +1,56 @@ module Tricorder.Session.TestTarget ( TestTarget (..)- , renderTestTarget- , parseTestTargets- , resolveTestTargets- , projectTestTargets+ , render+ , parse+ , resolve+ , project ) where import Data.Aeson (FromJSON (..), FromJSONKey, ToJSON (..), ToJSONKey) +import Tricorder.Session.CommandConfig (CommandConfig (..)) import Tricorder.Session.Config (Config (..))-import Tricorder.Session.Target (ComponentKind (..), Target (..), parseTarget, renderTarget)+import Tricorder.Session.Stage.Test.Config (TestConfig (..))+import Tricorder.Session.Target (ComponentKind (..), Target (..)) +import Tricorder.Session.Target qualified as Target + newtype TestTarget = TestTarget {getTestTarget :: Target} deriving stock (Eq, Generic, Ord, Show) deriving (FromJSON, ToJSON) via Target deriving (FromJSONKey, ToJSONKey) via Target -renderTestTarget :: TestTarget -> Text-renderTestTarget = renderTarget . getTestTarget+render :: TestTarget -> Text+render = Target.render . getTestTarget -- | Parse raw target strings (e.g. the @test_targets@ config) and project them -- onto their test suites — non-test entries are dropped.-parseTestTargets :: [Text] -> [TestTarget]-parseTestTargets = projectTestTargets . map parseTarget+parse :: [Text] -> [TestTarget]+parse = project . map Target.parse -- | [tag:test_targets_invariant] Project a target list onto its test suites — -- the only way to build a 'TestTargets', so the @test:@-only invariant holds by -- construction.-projectTestTargets :: [Target] -> [TestTarget]-projectTestTargets = mapMaybe mkTestTarget+project :: [Target] -> [TestTarget]+project = mapMaybe mk where- mkTestTarget tgt@(Qualified Test _) = Just $ TestTarget tgt- mkTestTarget tgt@(PackageQualified _ Test _) = Just $ TestTarget tgt- mkTestTarget _ = Nothing+ mk tgt@(Qualified Test _) = Just $ TestTarget tgt+ mk tgt@(PackageQualified _ Test _) = Just $ TestTarget tgt+ mk _ = Nothing --- | Resolve which test suites to run after a clean build. Either source — the--- explicit @test_targets@ config or the build 'targets' — is projected onto its--- @test:@ components (see 'projectTestTargets'), so non-test entries are--- dropped and the result only ever names test suites [ref:test_targets_invariant].-resolveTestTargets :: Config -> [Target] -> [TestTarget]-resolveTestTargets cfg targets = case cfg.testTargets of- Just explicit -> parseTestTargets explicit- Nothing -> projectTestTargets targets+-- | Resolve which test suites to run after a clean build. The explicit+-- source — @test.targets@, falling back to the deprecated top-level+-- @test_targets@ — is projected onto its @test:@ components (see+-- 'projectTestTargets'), so non-test entries are dropped and the result only+-- ever names test suites [ref:test_targets_invariant]. With neither source+-- set, falls back to deriving test targets from the build 'targets'.+resolve :: Config -> [Target] -> [TestTarget]+resolve cfg targets = case cfg.test.commandConfig.targets <|> cfg.testTargets of+ Just explicit -> parse explicit+ Nothing -> project targets
+ src/Tricorder/Session/Util.hs view
@@ -0,0 +1,17 @@+module Tricorder.Session.Util+ ( showList+ , indent+ )+where++import Data.Text qualified as T+++showList :: (a -> Text) -> [a] -> Text+showList f xs+ | null xs = "<empty list>"+ | otherwise = T.intercalate "\n" $ (("- " <>) . f) <$> xs+++indent :: Text -> Text+indent = T.unlines . fmap (" " <>) . T.lines
src/Tricorder/Session/WatchDirs.hs view
@@ -1,6 +1,6 @@ module Tricorder.Session.WatchDirs ( WatchDirs (..)- , resolveWatchDirs+ , resolve , sourceDirsForTarget ) where@@ -51,8 +51,8 @@ -- 1. @watch_dirs@ from config, if non-empty (used as-is relative to project root) -- 2. @hs-source-dirs@ inferred from cabal targets, if targets are set -- 3. Falls back to @["."]@ (project root) if neither is available-resolveWatchDirs :: ProjectRoot -> [CabalFile] -> Config -> [Target] -> WatchDirs-resolveWatchDirs projectRoot projectFiles cfg targets =+resolve :: ProjectRoot -> [CabalFile] -> Config -> [Target] -> WatchDirs+resolve projectRoot projectFiles cfg targets = case cfg.watchDirs of dirs@(_ : _) -> WatchDirs $ map (coerce projectRoot </>) dirs [] -> resolveWatchDirsFromTargets projectFiles targets
src/Tricorder/Socket/Server.hs view
@@ -18,7 +18,6 @@ import Effectful.State.Static.Shared qualified as State import Tricorder.Build (BuildId, BuildPhase, BuildState (..), Diagnostic)-import Tricorder.Daemon.DaemonInfo (DaemonInfo) import Tricorder.Daemon.IdleTimer (IdleTimer) import Tricorder.Runtime (SocketPath (..)) import Tricorder.Socket.Protocol@@ -57,7 +56,6 @@ , Exit :> es , IdleTimer :> es , Input BuildId :> es- , Input DaemonInfo :> es , Log :> es , Reader SocketPath :> es , Sub BuildPhase :> es@@ -78,7 +76,6 @@ , Exit :> es , IdleTimer :> es , Input BuildId :> es- , Input DaemonInfo :> es , Log :> es , Reader SocketPath :> es , State BuildPhase :> es@@ -101,7 +98,6 @@ , Exit :> es , IdleTimer :> es , Input BuildId :> es- , Input DaemonInfo :> es , Log :> es , State BuildPhase :> es , Sub BuildPhase :> es@@ -127,7 +123,6 @@ :: ( Conc :> es , Exit :> es , Input BuildId :> es- , Input DaemonInfo :> es , Log :> es , State BuildPhase :> es , Sub BuildPhase :> es@@ -164,7 +159,6 @@ respondOnce :: ( Input BuildId :> es- , Input DaemonInfo :> es , State BuildPhase :> es , UnixSocket :> es )@@ -177,7 +171,6 @@ respondWhenDone :: ( Conc :> es , Input BuildId :> es- , Input DaemonInfo :> es , State BuildPhase :> es , Sub BuildPhase :> es , UnixSocket :> es@@ -202,7 +195,6 @@ -- | Stream a JSON object after each state change event. watchStream :: ( Input BuildId :> es- , Input DaemonInfo :> es , State BuildPhase :> es , Sub BuildPhase :> es , UnixSocket :> es@@ -240,8 +232,7 @@ sendJson h val = sendLine h (decodeUtf8 (BSL.toStrict (encode val))) -mkBuildState :: (Input BuildId :> es, Input DaemonInfo :> es) => BuildPhase -> Eff es BuildState+mkBuildState :: (Input BuildId :> es) => BuildPhase -> Eff es BuildState mkBuildState phase = do- daemonInfo <- input buildId <- input- pure $ BuildState {daemonInfo, buildId, phase}+ pure $ BuildState {buildId, phase}
src/Tricorder/SourceLookup.hs view
@@ -14,7 +14,7 @@ import Atelier.Effects.Log qualified as Log -import Tricorder.Session.Command (Repl)+import Tricorder.Session.Repl (Repl) import Tricorder.SourceLookup.GhcPkg (GhcPkg) import Tricorder.SourceLookup.Hackage (Hackage) import Tricorder.SourceLookup.PackageId (PackageId (..))
src/Tricorder/SourceLookup/GhcPkg.hs view
@@ -16,7 +16,7 @@ import Data.Text qualified as T -import Tricorder.Session.Command (Repl (..))+import Tricorder.Session.Repl (Repl (..)) import Tricorder.SourceLookup.PackageId (PackageId (..))
test/Driver.hs view
@@ -1,2 +1,2 @@-{-# OPTIONS_GHC -F -pgmF tasty-discover #-}+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --tree-display #-}
test/Unit/Tricorder/Build/ByteSizeSpec.hs view
@@ -1,73 +1,61 @@-module Unit.Tricorder.Build.ByteSizeSpec (spec_ByteSize) where+module Unit.Tricorder.Build.ByteSizeSpec (test_ByteSize) where -import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Build.ByteSize (ByteSize (..), Unit (..)) import Tricorder.Build.ByteSize qualified as ByteSize -spec_ByteSize :: Spec-spec_ByteSize = describe "fromText" testFromText+test_ByteSize :: TestTree+test_ByteSize =+ testGroup+ "ByteSize"+ [ testGroup "fromText" testFromText+ ] -testFromText :: Spec-testFromText = do- it "parses no unit as bytes" do- ByteSize.fromText "1000" `shouldBe` Just (ByteSize 1000 B)-- it "parses bytes with unit" do- ByteSize.fromText "1000B" `shouldBe` Just (ByteSize 1000 B)-- it "parses decimal kilobytes" do- ByteSize.fromText "10kb" `shouldBe` Just (ByteSize 10 KB)-- it "parses binary kibibytes" do- ByteSize.fromText "10kib" `shouldBe` Just (ByteSize 10 KiB)-- it "parses decimal megabytes" do- ByteSize.fromText "1mb" `shouldBe` Just (ByteSize 1 MB)-- it "parses binary mebibytes" do- ByteSize.fromText "1mib" `shouldBe` Just (ByteSize 1 MiB)-- it "parses decimal gigabytes" do- ByteSize.fromText "1gb" `shouldBe` Just (ByteSize 1 GB)-- it "parses binary gibibytes" do- ByteSize.fromText "1gib" `shouldBe` Just (ByteSize 1 GiB)-- it "parses decimal terabytes" do- ByteSize.fromText "1tb" `shouldBe` Just (ByteSize 1 TB)-- it "parses binary tebibytes" do- ByteSize.fromText "1tib" `shouldBe` Just (ByteSize 1 TiB)-- it "parses decimal petabytes" do- ByteSize.fromText "1pb" `shouldBe` Just (ByteSize 1 PB)-- it "parses binary pebibytes" do- ByteSize.fromText "1pib" `shouldBe` Just (ByteSize 1 PiB)-- it "allows a space between the number and the unit" do- ByteSize.fromText "10 kb" `shouldBe` Just (ByteSize 10 KB)-- it "allows multiple spaces between the number and the unit" do- ByteSize.fromText "10 kb" `shouldBe` Just (ByteSize 10 KB)-- it "is case-insensitive on the unit" do- ByteSize.fromText "10KB" `shouldBe` Just (ByteSize 10 KB)- ByteSize.fromText "10Kb" `shouldBe` Just (ByteSize 10 KB)- ByteSize.fromText "10KiB" `shouldBe` Just (ByteSize 10 KiB)-- it "fails when there is no number" do- ByteSize.fromText "kb" `shouldBe` Nothing-- it "fails on an unrecognized unit" do- ByteSize.fromText "10xb" `shouldBe` Nothing-- it "fails on empty text" do- ByteSize.fromText "" `shouldBe` Nothing-- it "fails on unrelated text" do- ByteSize.fromText "hello world" `shouldBe` Nothing+testFromText :: [TestTree]+testFromText =+ [ testCase "parses no unit as bytes" do+ ByteSize.fromText "1000" @?= Just (ByteSize 1000 B)+ , testCase "parses bytes with unit" do+ ByteSize.fromText "1000B" @?= Just (ByteSize 1000 B)+ , testCase "parses decimal kilobytes" do+ ByteSize.fromText "10kb" @?= Just (ByteSize 10 KB)+ , testCase "parses binary kibibytes" do+ ByteSize.fromText "10kib" @?= Just (ByteSize 10 KiB)+ , testCase "parses decimal megabytes" do+ ByteSize.fromText "1mb" @?= Just (ByteSize 1 MB)+ , testCase "parses binary mebibytes" do+ ByteSize.fromText "1mib" @?= Just (ByteSize 1 MiB)+ , testCase "parses decimal gigabytes" do+ ByteSize.fromText "1gb" @?= Just (ByteSize 1 GB)+ , testCase "parses binary gibibytes" do+ ByteSize.fromText "1gib" @?= Just (ByteSize 1 GiB)+ , testCase "parses decimal terabytes" do+ ByteSize.fromText "1tb" @?= Just (ByteSize 1 TB)+ , testCase "parses binary tebibytes" do+ ByteSize.fromText "1tib" @?= Just (ByteSize 1 TiB)+ , testCase "parses decimal petabytes" do+ ByteSize.fromText "1pb" @?= Just (ByteSize 1 PB)+ , testCase "parses binary pebibytes" do+ ByteSize.fromText "1pib" @?= Just (ByteSize 1 PiB)+ , testCase "allows a space between the number and the unit" do+ ByteSize.fromText "10 kb" @?= Just (ByteSize 10 KB)+ , testCase "allows multiple spaces between the number and the unit" do+ ByteSize.fromText "10 kb" @?= Just (ByteSize 10 KB)+ , testCase "is case-insensitive on the unit" do+ ByteSize.fromText "10KB" @?= Just (ByteSize 10 KB)+ ByteSize.fromText "10Kb" @?= Just (ByteSize 10 KB)+ ByteSize.fromText "10KiB" @?= Just (ByteSize 10 KiB)+ , testCase "fails when there is no number" do+ ByteSize.fromText "kb" @?= Nothing+ , testCase "fails on an unrecognized unit" do+ ByteSize.fromText "10xb" @?= Nothing+ , testCase "fails on empty text" do+ ByteSize.fromText "" @?= Nothing+ , testCase "fails on unrelated text" do+ ByteSize.fromText "hello world" @?= Nothing+ ]
test/Unit/Tricorder/Build/EvalCommentSpec.hs view
@@ -1,177 +1,153 @@-module Unit.Tricorder.Build.EvalCommentSpec (spec_EvalComment) where+module Unit.Tricorder.Build.EvalCommentSpec (test_EvalComment) where -import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList, shouldSatisfy)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=)) import Text.Megaparsec (parse) import Tricorder.Build.EvalComment qualified as Eval -spec_EvalComment :: Spec-spec_EvalComment = do- describe "singleLineEvalCommentP" testSingleLine- describe "multiLineEvalCommentP" testMultiLine- describe "blockCommentEvalP" testBlockComment- describe "findComments" testFindComments+test_EvalComment :: TestTree+test_EvalComment =+ testGroup+ "EvalComment"+ [ testGroup "singleLineEvalCommentP" testSingleLine+ , testGroup "multiLineEvalCommentP" testMultiLine+ , testGroup "blockCommentEvalP" testBlockComment+ , testGroup "findComments" testFindComments+ ] -------------------------------------------------------------------------------- -- singleLineEvalCommentP -------------------------------------------------------------------------------- -testSingleLine :: Spec-testSingleLine = do- it "parses a basic expression" do+testSingleLine :: [TestTree]+testSingleLine =+ [ testCase "parses a basic expression" do parse Eval.singleLineEvalCommentP "" "-- $> 1 + 2"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "1 + 2"}-- it "handles no space between marker and expression" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "1 + 2"}+ , testCase "handles no space between marker and expression" do parse Eval.singleLineEvalCommentP "" "-- $>expr"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "expr"}-- it "strips leading whitespace from the expression" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "expr"}+ , testCase "strips leading whitespace from the expression" do parse Eval.singleLineEvalCommentP "" "-- $> expr"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "expr"}-- it "captures the full expression including inner spaces" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "expr"}+ , testCase "captures the full expression including inner spaces" do parse Eval.singleLineEvalCommentP "" "-- $> foo bar baz"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "foo bar baz"}-- it "stops at a newline, not consuming it" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "foo bar baz"}+ , testCase "stops at a newline, not consuming it" do parse Eval.singleLineEvalCommentP "" "-- $> expr\nnext line"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "expr"}-- it "fails when there is no expression after the marker" do- parse Eval.singleLineEvalCommentP "" "-- $>" `shouldSatisfy` isLeft-- it "fails on the multi-line opening marker" do- parse Eval.singleLineEvalCommentP "" "-- $$> expr -- <$$" `shouldSatisfy` isLeft-- it "fails on other text between comment start and eval marker" do- parse Eval.singleLineEvalCommentP "" "-- foo $> 1 + 2" `shouldSatisfy` isLeft-- it "fails on unrelated text" do- parse Eval.singleLineEvalCommentP "" "hello world" `shouldSatisfy` isLeft+ @?= Right Eval.Comment {lineNumber = 1, expression = "expr"}+ , testCase "fails when there is no expression after the marker" do+ assertBool "expected Left" $ isLeft (parse Eval.singleLineEvalCommentP "" "-- $>")+ , testCase "fails on the multi-line opening marker" do+ assertBool "expected Left" $ isLeft (parse Eval.singleLineEvalCommentP "" "-- $$> expr -- <$$")+ , testCase "fails on other text between comment start and eval marker" do+ assertBool "expected Left" $ isLeft (parse Eval.singleLineEvalCommentP "" "-- foo $> 1 + 2")+ , testCase "fails on unrelated text" do+ assertBool "expected Left" $ isLeft (parse Eval.singleLineEvalCommentP "" "hello world")+ ] -------------------------------------------------------------------------------- -- multiLineEvalCommentP -------------------------------------------------------------------------------- -testMultiLine :: Spec-testMultiLine = do- it "parses a single content line, stripping the -- prefix" do+testMultiLine :: [TestTree]+testMultiLine =+ [ testCase "parses a single content line, stripping the -- prefix" do parse Eval.multiLineEvalCommentP "" "-- $$>\n-- expr\n-- <$$"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "expr"}-- it "parses multiple content lines, stripping -- prefixes" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "expr"}+ , testCase "parses multiple content lines, stripping -- prefixes" do parse Eval.multiLineEvalCommentP "" "-- $$>\n-- foo\n-- bar\n-- <$$"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "foo\nbar"}-- it "preserves relative indentation after stripping -- prefix" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "foo\nbar"}+ , testCase "preserves relative indentation after stripping -- prefix" do parse Eval.multiLineEvalCommentP "" "-- $$>\n-- let x = 1\n-- y = 2\n-- in x + y\n-- <$$"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "let x = 1\n y = 2\nin x + y"}-- it "handles -- with no trailing space" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "let x = 1\n y = 2\nin x + y"}+ , testCase "handles -- with no trailing space" do parse Eval.multiLineEvalCommentP "" "-- $$>\n--expr\n-- <$$"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "expr"}-- it "parse multi-line eval comment in a single line" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "expr"}+ , testCase "parse multi-line eval comment in a single line" do parse Eval.multiLineEvalCommentP "" "-- $$> expr <$$"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "expr"}-- it "fails when the closing marker is absent" do- parse Eval.multiLineEvalCommentP "" "-- $$>\n-- expr" `shouldSatisfy` isLeft-- it "fails on the single-line marker" do- parse Eval.multiLineEvalCommentP "" "-- $> expr" `shouldSatisfy` isLeft-- it "fails on unrelated text" do- parse Eval.multiLineEvalCommentP "" "hello world" `shouldSatisfy` isLeft+ @?= Right Eval.Comment {lineNumber = 1, expression = "expr"}+ , testCase "fails when the closing marker is absent" do+ assertBool "expected Left" $ isLeft (parse Eval.multiLineEvalCommentP "" "-- $$>\n-- expr")+ , testCase "fails on the single-line marker" do+ assertBool "expected Left" $ isLeft (parse Eval.multiLineEvalCommentP "" "-- $> expr")+ , testCase "fails on unrelated text" do+ assertBool "expected Left" $ isLeft (parse Eval.multiLineEvalCommentP "" "hello world")+ ] -------------------------------------------------------------------------------- -- blockCommentEvalP -------------------------------------------------------------------------------- -testBlockComment :: Spec-testBlockComment = do- it "parses a single-line expression on its own line" do+testBlockComment :: [TestTree]+testBlockComment =+ [ testCase "parses a single-line expression on its own line" do parse Eval.blockCommentEvalP "" "{- $$>\n2 + 2\n<$$ -}"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "2 + 2"}-- it "parses an inline one-liner" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "2 + 2"}+ , testCase "parses an inline one-liner" do parse Eval.blockCommentEvalP "" "{- $$> 2 + 2 <$$ -}"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "2 + 2"}-- it "parses a multi-line expression preserving layout" do+ @?= Right Eval.Comment {lineNumber = 1, expression = "2 + 2"}+ , testCase "parses a multi-line expression preserving layout" do parse Eval.blockCommentEvalP "" "{- $$>\nlet x = 1\n y = 2\nin x + y\n<$$ -}"- `shouldBe` Right Eval.Comment {lineNumber = 1, expression = "let x = 1\n y = 2\nin x + y"}-- it "fails when the closing marker is absent" do- parse Eval.blockCommentEvalP "" "{- $$>\nexpr" `shouldSatisfy` isLeft-- it "fails on the line-comment multi-line eval marker" do- parse Eval.blockCommentEvalP "" "-- $$> expr" `shouldSatisfy` isLeft-- it "fails on the single-line eval marker" do- parse Eval.blockCommentEvalP "" "{- $> expr -}" `shouldSatisfy` isLeft-- it "fails on unrelated text" do- parse Eval.blockCommentEvalP "" "hello world" `shouldSatisfy` isLeft+ @?= Right Eval.Comment {lineNumber = 1, expression = "let x = 1\n y = 2\nin x + y"}+ , testCase "fails when the closing marker is absent" do+ assertBool "expected Left" $ isLeft (parse Eval.blockCommentEvalP "" "{- $$>\nexpr")+ , testCase "fails on the line-comment multi-line eval marker" do+ assertBool "expected Left" $ isLeft (parse Eval.blockCommentEvalP "" "-- $$> expr")+ , testCase "fails on the single-line eval marker" do+ assertBool "expected Left" $ isLeft (parse Eval.blockCommentEvalP "" "{- $> expr -}")+ , testCase "fails on unrelated text" do+ assertBool "expected Left" $ isLeft (parse Eval.blockCommentEvalP "" "hello world")+ ] -------------------------------------------------------------------------------- -- findComments -------------------------------------------------------------------------------- -testFindComments :: Spec-testFindComments = do- it "returns empty list for empty text" do- Eval.findComments "" `shouldMatchList` []-- it "returns empty list when there are no eval comments" do- Eval.findComments "hello world\nno comments here" `shouldMatchList` []-- it "finds a single single-line eval comment" do+testFindComments :: [TestTree]+testFindComments =+ [ testCase "returns empty list for empty text" do+ Eval.findComments "" @?= []+ , testCase "returns empty list when there are no eval comments" do+ Eval.findComments "hello world\nno comments here" @?= []+ , testCase "finds a single single-line eval comment" do Eval.findComments "x = 1\n-- $> x\ny = 2"- `shouldMatchList` [Eval.Comment {lineNumber = 2, expression = "x"}]-- it "finds multiple single-line eval comments in source order" do+ @?= [Eval.Comment {lineNumber = 2, expression = "x"}]+ , testCase "finds multiple single-line eval comments in source order" do Eval.findComments "-- $> a\n-- $> b"- `shouldMatchList` [ Eval.Comment {lineNumber = 1, expression = "a"}- , Eval.Comment {lineNumber = 2, expression = "b"}- ]-- it "reports correct line numbers" do+ @?= [ Eval.Comment {lineNumber = 1, expression = "a"}+ , Eval.Comment {lineNumber = 2, expression = "b"}+ ]+ , testCase "reports correct line numbers" do Eval.findComments "line1\nline2\n-- $> expr\nline4"- `shouldMatchList` [Eval.Comment {lineNumber = 3, expression = "expr"}]-- it "ignores lines that look like partial markers" do+ @?= [Eval.Comment {lineNumber = 3, expression = "expr"}]+ , testCase "ignores lines that look like partial markers" do Eval.findComments "-- $\n-- $> expr"- `shouldMatchList` [Eval.Comment {lineNumber = 2, expression = "expr"}]-- it "does not match an eval marker embedded in another comment" do- Eval.findComments "-- foo -- $> expr" `shouldMatchList` []-- it "does not match an inline eval marker appearing after code" do- Eval.findComments "x = 1 -- $> x" `shouldMatchList` []-- it "finds a multi-line eval comment, stripping -- prefixes" do+ @?= [Eval.Comment {lineNumber = 2, expression = "expr"}]+ , testCase "does not match an eval marker embedded in another comment" do+ Eval.findComments "-- foo -- $> expr" @?= []+ , testCase "does not match an inline eval marker appearing after code" do+ Eval.findComments "x = 1 -- $> x" @?= []+ , testCase "finds a multi-line eval comment, stripping -- prefixes" do Eval.findComments "-- $$>\n-- expr\n-- <$$"- `shouldMatchList` [Eval.Comment {lineNumber = 1, expression = "expr"}]-- it "finds a block comment eval" do+ @?= [Eval.Comment {lineNumber = 1, expression = "expr"}]+ , testCase "finds a block comment eval" do Eval.findComments "{- $$>\nexpr\n<$$ -}"- `shouldMatchList` [Eval.Comment {lineNumber = 1, expression = "expr"}]-- it "finds both single-line and multi-line eval comments" do+ @?= [Eval.Comment {lineNumber = 1, expression = "expr"}]+ , testCase "finds both single-line and multi-line eval comments" do Eval.findComments "-- $> a\n-- $$>\n-- b\n-- <$$"- `shouldMatchList` [ Eval.Comment {lineNumber = 1, expression = "a"}- , Eval.Comment {lineNumber = 2, expression = "b"}- ]-- it "finds both single-line and block comment eval comments" do+ @?= [ Eval.Comment {lineNumber = 1, expression = "a"}+ , Eval.Comment {lineNumber = 2, expression = "b"}+ ]+ , testCase "finds both single-line and block comment eval comments" do Eval.findComments "-- $> a\n{- $$>\nb\n<$$ -}"- `shouldMatchList` [ Eval.Comment {lineNumber = 1, expression = "a"}- , Eval.Comment {lineNumber = 2, expression = "b"}- ]+ @?= [ Eval.Comment {lineNumber = 1, expression = "a"}+ , Eval.Comment {lineNumber = 2, expression = "b"}+ ]+ ]
test/Unit/Tricorder/CLI/RenderSpec.hs view
@@ -1,30 +1,36 @@-module Unit.Tricorder.CLI.RenderSpec (spec_Render) where+module Unit.Tricorder.CLI.RenderSpec (test_Render) where -import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, testCase) +import Data.Text qualified as T+ import Tricorder.Build (Diagnostic (..), Severity (..)) import Tricorder.CLI.Render (diagnosticBlock) -spec_Render :: Spec-spec_Render = do- describe "diagnosticBlock" do- it "includes the one-liner prefix for an error" do- diagnosticBlock errMsg `shouldContainT` "E Foo.hs:10 type mismatch"-- it "includes the full text body after the first line" do- diagnosticBlock errMsg `shouldContainT` "\ntype mismatch"-- it "uses 'W' prefix for warnings" do- diagnosticBlock warnMsg `shouldContainT` "W Bar.hs:3 unused import"-- it "contains both title and text when they differ" do- let d = mixedMsg- diagnosticBlock d `shouldContainT` "short title"- diagnosticBlock d `shouldContainT` "full body of the message"+test_Render :: TestTree+test_Render =+ testGroup+ "Render"+ [ testGroup+ "diagnosticBlock"+ [ testCase "includes the one-liner prefix for an error" do+ diagnosticBlock errMsg `shouldContainT` "E Foo.hs:10 type mismatch"+ , testCase "includes the full text body after the first line" do+ diagnosticBlock errMsg `shouldContainT` "\ntype mismatch"+ , testCase "uses 'W' prefix for warnings" do+ diagnosticBlock warnMsg `shouldContainT` "W Bar.hs:3 unused import"+ , testCase "contains both title and text when they differ" do+ let d = mixedMsg+ diagnosticBlock d `shouldContainT` "short title"+ diagnosticBlock d `shouldContainT` "full body of the message"+ ]+ ] where- shouldContainT :: Text -> Text -> Expectation- shouldContainT a b = toString a `shouldContain` toString b+ shouldContainT :: Text -> Text -> Assertion+ shouldContainT a b =+ assertBool (toString a <> "\ndoes not contain\n" <> toString b) $ b `T.isInfixOf` a --------------------------------------------------------------------------------
test/Unit/Tricorder/Daemon/BuildStateSpec.hs view
@@ -1,8 +1,9 @@-module Unit.Tricorder.Daemon.BuildStateSpec (spec_BuildState) where+module Unit.Tricorder.Daemon.BuildStateSpec (test_BuildState) where import Data.Aeson (eitherDecode, encode) import Data.Time (UTCTime (..), fromGregorian)-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Build ( BuildId (..)@@ -13,74 +14,75 @@ , Severity (..) ) import Tricorder.Build.Duration (Duration (..))-import Tricorder.Daemon.DaemonInfo (DaemonInfo (..)) import Tricorder.Build qualified as Build import Tricorder.Build.EvalComment qualified as Eval -spec_BuildState :: Spec-spec_BuildState = do- describe "JSON round-trip" do- it "survives Unicode smart quotes in message text" do- let msg =- Diagnostic- { severity = SWarning- , file = "<interactive>"- , line = 2- , col = 8- , endLine = 2- , endCol = 8- , title = "Found \8216qualified\8217 in prepositive position"- , text =- "Found \8216qualified\8217 in prepositive position\n Suggested fixes:\n \8226 Place \8216qualified\8217 after the module name."- }- bs = mkBuildState [msg]- eitherDecode (encode bs) `shouldBe` Right bs-- it "survives control characters in message text" do- let msg =- Diagnostic- { severity = SWarning- , file = "<interactive>"- , line = 1- , col = 1- , endLine = 1- , endCol = 1- , title = "text with \CAN control \EM chars and \ESC[1m ANSI \ESC[0m codes"- , text = "text with \CAN control \EM chars and \ESC[1m ANSI \ESC[0m codes"- }- bs = mkBuildState [msg]- eitherDecode (encode bs) `shouldBe` Right bs-- it "survives curly double quotes in message text" do- let msg =- Diagnostic- { severity = SWarning- , file = "<interactive>"- , line = 1- , col = 1- , endLine = 1- , endCol = 1- , title = "\8220Place qualified after the module name.\8221"- , text = "\8220Place qualified after the module name.\8221"- }- bs = mkBuildState [msg]- eitherDecode (encode bs) `shouldBe` Right bs-- -- Guards the wire format for the BuildFailed phase: the captured- -- cabal/build error (multi-line, Unicode) must round-trip intact so- -- the CLI/UI clients can render it.- it "survives a BuildFailed phase with a multi-line message" do- let bs =- mkBuildState [] :: BuildState- failed =- bs- { phase =- Build.Failed- "cabal: Could not resolve dependencies:\n[__0] trying: \8216base\8217\nrejecting: ..."- }- eitherDecode (encode failed) `shouldBe` Right failed+test_BuildState :: TestTree+test_BuildState =+ testGroup+ "BuildState"+ [ testGroup+ "JSON round-trip"+ [ testCase "survives Unicode smart quotes in message text" do+ let msg =+ Diagnostic+ { severity = SWarning+ , file = "<interactive>"+ , line = 2+ , col = 8+ , endLine = 2+ , endCol = 8+ , title = "Found \8216qualified\8217 in prepositive position"+ , text =+ "Found \8216qualified\8217 in prepositive position\n Suggested fixes:\n \8226 Place \8216qualified\8217 after the module name."+ }+ bs = mkBuildState [msg]+ eitherDecode (encode bs) @?= Right bs+ , testCase "survives control characters in message text" do+ let msg =+ Diagnostic+ { severity = SWarning+ , file = "<interactive>"+ , line = 1+ , col = 1+ , endLine = 1+ , endCol = 1+ , title = "text with \CAN control \EM chars and \ESC[1m ANSI \ESC[0m codes"+ , text = "text with \CAN control \EM chars and \ESC[1m ANSI \ESC[0m codes"+ }+ bs = mkBuildState [msg]+ eitherDecode (encode bs) @?= Right bs+ , testCase "survives curly double quotes in message text" do+ let msg =+ Diagnostic+ { severity = SWarning+ , file = "<interactive>"+ , line = 1+ , col = 1+ , endLine = 1+ , endCol = 1+ , title = "\8220Place qualified after the module name.\8221"+ , text = "\8220Place qualified after the module name.\8221"+ }+ bs = mkBuildState [msg]+ eitherDecode (encode bs) @?= Right bs+ , -- Guards the wire format for the BuildFailed phase: the captured+ -- cabal/build error (multi-line, Unicode) must round-trip intact so+ -- the CLI/UI clients can render it.+ testCase "survives a BuildFailed phase with a multi-line message" do+ let bs =+ mkBuildState [] :: BuildState+ failed =+ bs+ { phase =+ Build.Failed+ "cabal: Could not resolve dependencies:\n[__0] trying: \8216base\8217\nrejecting: ..."+ }+ eitherDecode (encode failed) @?= Right failed+ ]+ ] mkBuildState :: [Diagnostic] -> BuildState@@ -97,13 +99,6 @@ } ) $ PostBuild mempty Eval.NoneFound- , daemonInfo =- DaemonInfo- { targets = []- , watchDirs = []- , sockPath = ""- , logFile = ""- } } where epoch = UTCTime (fromGregorian 1970 1 1) 0
test/Unit/Tricorder/Daemon/BuilderSpec.hs view
@@ -1,7 +1,8 @@-module Unit.Tricorder.Daemon.BuilderSpec (spec_Builder) where+module Unit.Tricorder.Daemon.BuilderSpec (test_Builder) where import Data.Time (UTCTime (..), addUTCTime, fromGregorian)-import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Data.Map.Strict qualified as Map import Data.Set qualified as Set@@ -19,20 +20,23 @@ import Tricorder.Session.WatchDirs (WatchDirs (..)) -spec_Builder :: Spec-spec_Builder = do- describe "extractTitle" testExtractTitle- describe "compileBuildResults" testCompileBuildResults- describe "resolveKnownTargets" testResolveKnownTargets+test_Builder :: TestTree+test_Builder =+ testGroup+ "Builder"+ [ testGroup "extractTitle" testExtractTitle+ , testGroup "compileBuildResults" testCompileBuildResults+ , testGroup "resolveKnownTargets" testResolveKnownTargets+ ] data StopSignal = StopSignal deriving stock (Show) -testCompileBuildResults :: Spec-testCompileBuildResults = do- it "uses NewLoadResult's times to calculate duration" do+testCompileBuildResults :: [TestTree]+testCompileBuildResults =+ [ testCase "uses NewLoadResult's times to calculate duration" do let (_, r) = compileBuildResults root@@ -50,8 +54,8 @@ , diagnostics = [] } }- r.duration `shouldBe` Duration 10_000- it "merges with existing results" do+ r.duration @?= Duration 10_000+ , testCase "merges with existing results" do let (m, _) = compileBuildResults root watchDirs (Map.fromList [(errMsg.file, [errMsg])]) $ NewLoadResult@@ -67,12 +71,11 @@ } } m- `shouldBe` fromList+ @?= fromList [ (warnMsg.file, [warnMsg]) , (errMsg.file, [errMsg]) ]-- it "returns a BuildResult" do+ , testCase "returns a BuildResult" do let (_, r) = compileBuildResults root watchDirs mempty $ NewLoadResult@@ -94,7 +97,8 @@ , moduleCount = 2 , diagnostics = [warnMsg] }- r `shouldBe` expected+ r @?= expected+ ] where root = ProjectRoot "/" watchDirs = WatchDirs ["/src"]@@ -104,9 +108,9 @@ -- resolveKnownTargets tests -------------------------------------------------------------------------------- -testResolveKnownTargets :: Spec-testResolveKnownTargets = do- it "uses :show modules as the primary source for path↔name mapping" do+testResolveKnownTargets :: [TestTree]+testResolveKnownTargets =+ [ testCase "uses :show modules as the primary source for path↔name mapping" do let result = emptyLr { loadedModules =@@ -119,19 +123,18 @@ , targetNames = ["Foo"] } resolveKnownTargets Map.empty result- `shouldBe` Map.fromList+ @?= Map.fromList [ ( "/abs/src/Foo.hs" , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"} ) ]-- -- Regression test for the stale-results bug. After a failed compile, the- -- module disappears from :show modules but stays in :show targets. The- -- prior state's entry must be carried over so the dispatcher continues to- -- see the file as "known" and issues :reload (not :add) when the user- -- fixes the error.- it "carries over prior state for targets that are no longer in :show modules" do+ , -- Regression test for the stale-results bug. After a failed compile, the+ -- module disappears from :show modules but stays in :show targets. The+ -- prior state's entry must be carried over so the dispatcher continues to+ -- see the file as "known" and issues :reload (not :add) when the user+ -- fixes the error.+ testCase "carries over prior state for targets that are no longer in :show modules" do let prev = Map.fromList [@@ -144,9 +147,8 @@ { loadedModules = Map.empty -- Foo failed to compile , targetNames = ["Foo"] -- but is still a target }- resolveKnownTargets prev result `shouldBe` prev-- it "drops targets that are no longer in :show targets" do+ resolveKnownTargets prev result @?= prev+ , testCase "drops targets that are no longer in :show targets" do let prev = Map.fromList [@@ -155,13 +157,13 @@ ) ] result = emptyLr {loadedModules = Map.empty, targetNames = []}- resolveKnownTargets prev result `shouldBe` Map.empty-- -- Dropped from the path-keyed map because we have no path↔name entry;- -- the dispatcher still handles them via 'KnownTargetNames'.- it "drops targets that have neither a current :show modules entry nor prior state" do+ resolveKnownTargets prev result @?= Map.empty+ , -- Dropped from the path-keyed map because we have no path↔name entry;+ -- the dispatcher still handles them via 'KnownTargetNames'.+ testCase "drops targets that have neither a current :show modules entry nor prior state" do let result = emptyLr {loadedModules = Map.empty, targetNames = ["BrandNew"]}- resolveKnownTargets Map.empty result `shouldBe` Map.empty+ resolveKnownTargets Map.empty result @?= Map.empty+ ] where emptyLr = LoadResult@@ -177,14 +179,13 @@ -- extractTitle tests -------------------------------------------------------------------------------- -testExtractTitle :: Spec-testExtractTitle = do- it "returns empty string for empty message" do- extractTitle [] `shouldBe` ""-- -- New GHC style: header ends with [GHC-XXXXX], content on body lines.- -- Captured from GHC 9.10.2 with -Weverything.- it "extracts first body line for error with [GHC-XXXXX] code" do+testExtractTitle :: [TestTree]+testExtractTitle =+ [ testCase "returns empty string for empty message" do+ extractTitle [] @?= ""+ , -- New GHC style: header ends with [GHC-XXXXX], content on body lines.+ -- Captured from GHC 9.10.2 with -Weverything.+ testCase "extracts first body line for error with [GHC-XXXXX] code" do extractTitle [ "src/Tricorder/Config.hs:39:20: error: [GHC-83865]" , " \8226 Couldn't match expected type 'Int' with actual type 'Bool'"@@ -194,9 +195,8 @@ , "39 | _deliberateError = True" , " | ^^^^" ]- `shouldBe` "\8226 Couldn't match expected type 'Int' with actual type 'Bool'"-- it "extracts first body line for warning with [GHC-XXXXX] [-Wfoo] codes" do+ @?= "\8226 Couldn't match expected type 'Int' with actual type 'Bool'"+ , testCase "extracts first body line for warning with [GHC-XXXXX] [-Wfoo] codes" do extractTitle [ "src/Tricorder/Config.hs:38:26: warning: [GHC-55631] [-Wmissing-deriving-strategies]" , " No deriving strategy specified. Did you want stock, newtype, or anyclass?"@@ -204,35 +204,30 @@ , "38 | data TestWarn = TestWarn deriving (Eq)" , " | ^^^^^^^^^^^^^" ]- `shouldBe` "No deriving strategy specified. Did you want stock, newtype, or anyclass?"-- -- Old GHC style: message text is inline on the header line.- it "extracts inline content for old-style single-line error" do+ @?= "No deriving strategy specified. Did you want stock, newtype, or anyclass?"+ , -- Old GHC style: message text is inline on the header line.+ testCase "extracts inline content for old-style single-line error" do extractTitle ["GHCi.hs:70:1: error: Parse error: naked expression at top level"]- `shouldBe` "Parse error: naked expression at top level"-- it "extracts inline content for old-style Warning (capital W)" do+ @?= "Parse error: naked expression at top level"+ , testCase "extracts inline content for old-style Warning (capital W)" do extractTitle ["GHCi.hs:81:1: Warning: Defined but not used: \8216foo\8217"]- `shouldBe` "Defined but not used: \8216foo\8217"-- -- Multi-line without any inline message: position-only or "Warning:" header.- it "extracts first body line when header has position only" do+ @?= "Defined but not used: \8216foo\8217"+ , -- Multi-line without any inline message: position-only or "Warning:" header.+ testCase "extracts first body line when header has position only" do extractTitle [ "GHCi.hs:72:13:" , " No instance for (Num ([String] -> [String]))" , " arising from the literal '1'" ]- `shouldBe` "No instance for (Num ([String] -> [String]))"-- it "extracts first body line when header ends with 'Warning:'" do+ @?= "No instance for (Num ([String] -> [String]))"+ , testCase "extracts first body line when header ends with 'Warning:'" do extractTitle [ "/src/TrieSpec.hs:(192,7)-(193,76): Warning:" , " A do-notation statement discarded a result of type '[()]'" ]- `shouldBe` "A do-notation statement discarded a result of type '[()]'"-- -- Source display lines (pipe/caret) must be skipped.- it "skips source display lines when scanning body" do+ @?= "A do-notation statement discarded a result of type '[()]'"+ , -- Source display lines (pipe/caret) must be skipped.+ testCase "skips source display lines when scanning body" do extractTitle [ "file.hs:1:1: error: [GHC-12345]" , " |"@@ -240,15 +235,15 @@ , " | ^^^" , " actual content here" ]- `shouldBe` "actual content here"-- -- ANSI-escaped header (colour output): strip escapes before searching.- it "handles ANSI-escaped headers" do+ @?= "actual content here"+ , -- ANSI-escaped header (colour output): strip escapes before searching.+ testCase "handles ANSI-escaped headers" do extractTitle [ "\ESC[;1msrc/Types.hs:11:1: \ESC[35mwarning:\ESC[0m \ESC[35m[-Wunused-imports]\ESC[0m" , " The import of 'Data.Data' is redundant" ]- `shouldBe` "The import of 'Data.Data' is redundant"+ @?= "The import of 'Data.Data' is redundant"+ ] --------------------------------------------------------------------------------
test/Unit/Tricorder/Daemon/DispatchSpec.hs view
@@ -1,6 +1,7 @@-module Unit.Tricorder.Daemon.DispatchSpec (spec_Dispatch) where+module Unit.Tricorder.Daemon.DispatchSpec (test_Dispatch) where -import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList, shouldSatisfy)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=)) import Data.Map.Strict qualified as Map import Data.Set qualified as Set@@ -17,77 +18,74 @@ import Tricorder.Session.WatchDirs (WatchDirs (..)) -spec_Dispatch :: Spec-spec_Dispatch = do- describe "fileMatchesAnyTarget" testFileMatchesAnyTarget- describe "mergeDiagnostics" testMergeDiagnostics- describe "filterToWatchDirs" testFilterToWatchDirs+test_Dispatch :: TestTree+test_Dispatch =+ testGroup+ "Dispatch"+ [ testGroup "fileMatchesAnyTarget" testFileMatchesAnyTarget+ , testGroup "mergeDiagnostics" testMergeDiagnostics+ , testGroup "filterToWatchDirs" testFilterToWatchDirs+ ] -------------------------------------------------------------------------------- -- fileMatchesAnyTarget tests -------------------------------------------------------------------------------- -testFileMatchesAnyTarget :: Spec-testFileMatchesAnyTarget = do- it "matches when the path's uppercase-suffix equals a target" do+testFileMatchesAnyTarget :: [TestTree]+testFileMatchesAnyTarget =+ [ testCase "matches when the path's uppercase-suffix equals a target" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "Tricorder.Version")) "./tricorder/src/Tricorder/Version.hs"- `shouldBe` True-- it "matches a single-segment module" do+ @?= True+ , testCase "matches a single-segment module" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "Main")) "./app/Main.hs"- `shouldBe` True-- it "does not match when no uppercase-suffix equals a target" do+ @?= True+ , testCase "does not match when no uppercase-suffix equals a target" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "Other.Module")) "./tricorder/src/Tricorder/Version.hs"- `shouldBe` False-- it "does not match a lowercase-prefix even if textually contained" do+ @?= False+ , testCase "does not match a lowercase-prefix even if textually contained" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "src.Tricorder.Version")) "./tricorder/src/Tricorder/Version.hs"- `shouldBe` False-- it "handles .lhs extension" do+ @?= False+ , testCase "handles .lhs extension" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "Foo.Bar")) "./src/Foo/Bar.lhs"- `shouldBe` True-- -- GHCi renders a target whose module name is ambiguous across home units- -- (every executable/test 'Main') as its source path, e.g. "app/Main.hs".- it "matches a path-shaped target on directory-segment boundaries" do+ @?= True+ , -- GHCi renders a target whose module name is ambiguous across home units+ -- (every executable/test 'Main') as its source path, e.g. "app/Main.hs".+ testCase "matches a path-shaped target on directory-segment boundaries" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "app/Main.hs")) "./tricorder/app/Main.hs"- `shouldBe` True-- it "does not match a path-shaped target on a partial segment" do+ @?= True+ , testCase "does not match a path-shaped target on a partial segment" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "pp/Main.hs")) "./tricorder/app/Main.hs"- `shouldBe` False-- it "does not match a path-shaped target for a different file" do+ @?= False+ , testCase "does not match a path-shaped target for a different file" do fileMatchesAnyTarget (KnownTargetNames (Set.singleton "daemon/Main.hs")) "./tricorder/app/Main.hs"- `shouldBe` False+ @?= False+ ] -------------------------------------------------------------------------------- -- mergeDiagnostics tests -------------------------------------------------------------------------------- -testMergeDiagnostics :: Spec-testMergeDiagnostics = do- it "retains diagnostics from files not in compiledFiles" do+testMergeDiagnostics :: [TestTree]+testMergeDiagnostics =+ [ testCase "retains diagnostics from files not in compiledFiles" do -- Foo has an error, Bar has a warning. -- Only Foo is recompiled (and fixed). Bar is unchanged, so Bar's -- warning must survive.@@ -101,9 +99,8 @@ , diagnostics = [] } let merged = mergeDiagnostics prev result- Map.lookup warnMsg.file merged `shouldBe` Just [warnMsg]-- it "clears diagnostics when a recompiled file now has no issues" do+ Map.lookup warnMsg.file merged @?= Just [warnMsg]+ , testCase "clears diagnostics when a recompiled file now has no issues" do let prev = Map.fromList [(errMsg.file, [errMsg])] result = LoadResult@@ -114,9 +111,8 @@ , diagnostics = [] } let merged = mergeDiagnostics prev result- Map.lookup errMsg.file merged `shouldBe` Nothing-- it "replaces diagnostics for recompiled files" do+ Map.lookup errMsg.file merged @?= Nothing+ , testCase "replaces diagnostics for recompiled files" do let newErr = errMsg {title = "new error", text = "new error\n"} prev = Map.fromList [(errMsg.file, [errMsg])] result =@@ -128,9 +124,8 @@ , diagnostics = [newErr] } let merged = mergeDiagnostics prev result- Map.lookup errMsg.file merged `shouldBe` Just [newErr]-- it "accumulates diagnostics for newly seen files" do+ Map.lookup errMsg.file merged @?= Just [newErr]+ , testCase "accumulates diagnostics for newly seen files" do let result = LoadResult { moduleCount = 1@@ -140,26 +135,27 @@ , diagnostics = [warnMsg] } let merged = mergeDiagnostics Map.empty result- Map.lookup warnMsg.file merged `shouldBe` Just [warnMsg]-- describe "when the cycle reports none" $ it "clears a stale location-less diagnostic" do- -- <no location info> is never in compiledFiles, so without special- -- handling it would persist forever. A cycle with no location-less- -- diagnostic must evict it.- let noLoc = errMsg {file = "<no location info>"}- prev = Map.fromList [(noLoc.file, [noLoc])]- result =- LoadResult- { moduleCount = 1- , compiledFiles = Set.singleton errMsg.file- , loadedModules = Map.empty- , targetNames = []- , diagnostics = []- }- let merged = mergeDiagnostics prev result- Map.lookup noLoc.file merged `shouldBe` Nothing-- it "refreshes a location-less diagnostic that is still present" do+ Map.lookup warnMsg.file merged @?= Just [warnMsg]+ , testGroup+ "when the cycle reports none"+ [ testCase "clears a stale location-less diagnostic" do+ -- <no location info> is never in compiledFiles, so without special+ -- handling it would persist forever. A cycle with no location-less+ -- diagnostic must evict it.+ let noLoc = errMsg {file = "<no location info>"}+ prev = Map.fromList [(noLoc.file, [noLoc])]+ result =+ LoadResult+ { moduleCount = 1+ , compiledFiles = Set.singleton errMsg.file+ , loadedModules = Map.empty+ , targetNames = []+ , diagnostics = []+ }+ let merged = mergeDiagnostics prev result+ Map.lookup noLoc.file merged @?= Nothing+ ]+ , testCase "refreshes a location-less diagnostic that is still present" do let noLoc = errMsg {file = "<no location info>"} prev = Map.fromList [(noLoc.file, [noLoc])] result =@@ -171,83 +167,82 @@ , diagnostics = [noLoc] } let merged = mergeDiagnostics prev result- Map.lookup noLoc.file merged `shouldBe` Just [noLoc]+ Map.lookup noLoc.file merged @?= Just [noLoc]+ ] -------------------------------------------------------------------------------- -- filterToWatchDirs tests -------------------------------------------------------------------------------- -testFilterToWatchDirs :: Spec-testFilterToWatchDirs = do- let root = "/project"- watchDirs = WatchDirs ["/project/src"]-- it "keeps diagnostics under a watched directory" do+testFilterToWatchDirs :: [TestTree]+testFilterToWatchDirs =+ [ testCase "keeps diagnostics under a watched directory" do -- ./src/Foo.hs is what toRelative produces for an absolute project file let d = errMsg {file = "./src/Foo.hs"}- filterToWatchDirs root watchDirs [d] `shouldBe` [d]-- it "keeps diagnostics under \".\" watched directory" do+ filterToWatchDirs root watchDirs [d] @?= [d]+ , testCase "keeps diagnostics under \".\" watched directory" do let d = errMsg {file = "src/Foo.hs"}- filterToWatchDirs root (WatchDirs ["."]) [d] `shouldMatchList` [d]-- it "drops diagnostics from outside the project (e.g. Nix store .h files)" do+ filterToWatchDirs root (WatchDirs ["."]) [d] @?= [d]+ , testCase "drops diagnostics from outside the project (e.g. Nix store .h files)" do let d = errMsg {file = "/nix/store/abc123/ghcautoconf.h"}- filterToWatchDirs root watchDirs [d] `shouldBe` []-- it "drops diagnostics with mangled CPP filenames" do+ filterToWatchDirs root watchDirs [d] @?= []+ , testCase "drops diagnostics with mangled CPP filenames" do -- The ghcid parser produces "In file included from <path>" as the file -- field for GCC-style CPP include-chain messages. let d = errMsg {file = "In file included from src/Foo.hs"}- filterToWatchDirs root watchDirs [d] `shouldBe` []-- it "drops mangled CPP filenames when watchDirs is [\".\"] (project root)" do+ filterToWatchDirs root watchDirs [d] @?= []+ , testCase "drops mangled CPP filenames when watchDirs is [\".\"] (project root)" do -- With watchDirs=["."], the watch dir resolves to projectRoot itself. -- A mangled path joined onto projectRoot would incorrectly start with -- projectRoot+"/", so this case requires an explicit guard. let d = errMsg {file = "In file included from src/Foo.hs"}- filterToWatchDirs root (WatchDirs ["."]) [d] `shouldBe` []-- it "passes everything through when watchDirs is empty" do+ filterToWatchDirs root (WatchDirs ["."]) [d] @?= []+ , testCase "passes everything through when watchDirs is empty" do let d = errMsg {file = "/nix/store/abc123/ghcautoconf.h"}- filterToWatchDirs root (WatchDirs []) [d] `shouldBe` [d]-- it "works with the '.' fallback watch dir (whole project root)" do+ filterToWatchDirs root (WatchDirs []) [d] @?= [d]+ , testCase "works with the '.' fallback watch dir (whole project root)" do let d = errMsg {file = "./src/Foo.hs"} nixD = errMsg {file = "/nix/store/abc123/ghcautoconf.h"}- filterToWatchDirs root (WatchDirs ["."]) [d, nixD] `shouldBe` [d]-- describe "when diagnostic has no path it" $ it "keeps location-less <no location info> errors" do- -- A home-unit GHC plugin that can't load under --enable-multi-repl- -- produces a <no location info> error. It has no path to test against a- -- watch dir, but must survive or the failed build reads as clean.- let d = errMsg {file = "<no location info>"}- filterToWatchDirs root watchDirs [d] `shouldBe` [d]-- it "does not treat a real <-prefixed path as a location-less marker" do+ filterToWatchDirs root (WatchDirs ["."]) [d, nixD] @?= [d]+ , testGroup+ "when diagnostic has no path it"+ [ testCase "keeps location-less <no location info> errors" do+ -- A home-unit GHC plugin that can't load under --enable-multi-repl+ -- produces a <no location info> error. It has no path to test against a+ -- watch dir, but must survive or the failed build reads as clean.+ let d = errMsg {file = "<no location info>"}+ filterToWatchDirs root watchDirs [d] @?= [d]+ ]+ , testCase "does not treat a real <-prefixed path as a location-less marker" do -- isLocationLess requires a closing '>'. A real (if exotic) path that -- merely starts with '<' is an ordinary out-of-watch file and must be -- dropped, not kept as a build-level marker. let d = errMsg {file = "<generated>/Foo.hs"}- filterToWatchDirs root watchDirs [d] `shouldBe` []-- describe "when its only error is out of watch dirs" $ it "a failed load does not read as clean" do- -- collectResult only injects its synthetic failure when no SError is- -- present. Here GHCi Failed with a single *located* error in a file- -- outside the watch dirs, so collectResult adds no synthetic — and then- -- filterToWatchDirs drops the out-of-watch error, leaving nothing. The- -- Builder pipeline composes preserveFailureVisibility after filtering to- -- re-attach the failure, so a failed build never survives with zero- -- diagnostics.- let reloadOutput =- [ "/other/Dep.hs:5:1: error: boom"- , "Failed, 0 modules loaded."- ]- result = collectResult root reloadOutput [] []- filtered = filterToWatchDirs root watchDirs result.diagnostics- preserveFailureVisibility result.diagnostics filtered- `shouldSatisfy` (not . null)+ filterToWatchDirs root watchDirs [d] @?= []+ , testGroup+ "when its only error is out of watch dirs"+ [ testCase "a failed load does not read as clean" do+ -- collectResult only injects its synthetic failure when no SError is+ -- present. Here GHCi Failed with a single *located* error in a file+ -- outside the watch dirs, so collectResult adds no synthetic — and then+ -- filterToWatchDirs drops the out-of-watch error, leaving nothing. The+ -- Builder pipeline composes preserveFailureVisibility after filtering to+ -- re-attach the failure, so a failed build never survives with zero+ -- diagnostics.+ let reloadOutput =+ [ "/other/Dep.hs:5:1: error: boom"+ , "Failed, 0 modules loaded."+ ]+ result = collectResult root reloadOutput [] []+ filtered = filterToWatchDirs root watchDirs result.diagnostics+ assertBool "expected at least one diagnostic"+ $ not (null (preserveFailureVisibility result.diagnostics filtered))+ ]+ ]+ where+ root = "/project"+ watchDirs = WatchDirs ["/project/src"] --------------------------------------------------------------------------------
test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs view
@@ -1,6 +1,7 @@-module Unit.Tricorder.Daemon.GhciSession.GhciParserSpec (spec_GhciParser) where+module Unit.Tricorder.Daemon.GhciSession.GhciParserSpec (test_GhciParser) where -import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=)) import Tricorder.Build (Diagnostic (..), Severity (..)) import Tricorder.Daemon.GhciSession.GhciParser@@ -19,35 +20,42 @@ ) -spec_GhciParser :: Spec-spec_GhciParser = do- describe "parseReload" do- describe "clean build" testCleanBuild- describe "with errors and warnings" testErrors- describe "with -fhide-source-paths (no Loading items)" testHideSourcePaths- describe "with <no location info> errors" testNoLocationInfo- describe "with Loaded GHCi configuration" testLoadedConfig-- describe "parseShowModules" do- describe "typical output" testShowModules- describe "empty / blank input" testShowModulesEmpty-- describe "parseShowTargets" testShowTargets-- describe "collectResultCustom" do- describe "<no location info> plugin load failure" testPluginLoadFailure-- describe "collectResult" do- describe "failed load with no located error" testUnattributedFailure+test_GhciParser :: TestTree+test_GhciParser =+ testGroup+ "GhciParser"+ [ testGroup+ "parseReload"+ [ testGroup "clean build" testCleanBuild+ , testGroup "with errors and warnings" testErrors+ , testGroup "with -fhide-source-paths (no Loading items)" testHideSourcePaths+ , testGroup "with <no location info> errors" testNoLocationInfo+ , testGroup "with Loaded GHCi configuration" testLoadedConfig+ ]+ , testGroup+ "parseShowModules"+ [ testGroup "typical output" testShowModules+ , testGroup "empty / blank input" testShowModulesEmpty+ ]+ , testGroup "parseShowTargets" testShowTargets+ , testGroup+ "collectResultCustom"+ [ testGroup "<no location info> plugin load failure" testPluginLoadFailure+ ]+ , testGroup+ "collectResult"+ [ testGroup "failed load with no located error" testUnattributedFailure+ ]+ ] -------------------------------------------------------------------------------- -- parseReload: clean build -------------------------------------------------------------------------------- -testCleanBuild :: Spec-testCleanBuild = do- it "produces GLoading items for each compiled module" do+testCleanBuild :: [TestTree]+testCleanBuild =+ [ testCase "produces GLoading items for each compiled module" do let input = [ "[1 of 3] Compiling Tricorder.Build ( src/Tricorder.Build.hs, interpreted )" , "[2 of 3] Compiling Tricorder.Session ( src/Tricorder/Session.hs, interpreted )"@@ -55,118 +63,115 @@ , "Ok, 3 modules loaded." ] parseReload input- `shouldBe` [ GLoading- GhciLoading- { index = 1- , total = 3- , moduleName = "Tricorder.Build"- , sourceFile = "src/Tricorder.Build.hs"- }- , GLoading- GhciLoading- { index = 2- , total = 3- , moduleName = "Tricorder.Session"- , sourceFile = "src/Tricorder/Session.hs"- }- , GLoading GhciLoading {index = 3, total = 3, moduleName = "Main", sourceFile = "app/Main.hs"}- , GSummary LoadSucceeded- ]-- it "handles padded module index (e.g. [ 1 of 47])" do+ @?= [ GLoading+ GhciLoading+ { index = 1+ , total = 3+ , moduleName = "Tricorder.Build"+ , sourceFile = "src/Tricorder.Build.hs"+ }+ , GLoading+ GhciLoading+ { index = 2+ , total = 3+ , moduleName = "Tricorder.Session"+ , sourceFile = "src/Tricorder/Session.hs"+ }+ , GLoading GhciLoading {index = 3, total = 3, moduleName = "Main", sourceFile = "app/Main.hs"}+ , GSummary LoadSucceeded+ ]+ , testCase "handles padded module index (e.g. [ 1 of 47])" do let input = [ "[ 1 of 47] Compiling Main ( app/Main.hs, interpreted )" , "Ok, 1 module loaded." ] parseReload input- `shouldBe` [ GLoading GhciLoading {index = 1, total = 47, moduleName = "Main", sourceFile = "app/Main.hs"}- , GSummary LoadSucceeded- ]-- describe "when only summary line" $ it "returns the summary outcome" do- parseReload ["Ok, 0 modules loaded."] `shouldBe` [GSummary LoadSucceeded]+ @?= [ GLoading GhciLoading {index = 1, total = 47, moduleName = "Main", sourceFile = "app/Main.hs"}+ , GSummary LoadSucceeded+ ]+ , testGroup+ "when only summary line"+ [ testCase "returns the summary outcome" do+ parseReload ["Ok, 0 modules loaded."] @?= [GSummary LoadSucceeded]+ ]+ ] -------------------------------------------------------------------------------- -- parseReload: errors and warnings -------------------------------------------------------------------------------- -testErrors :: Spec-testErrors = do- it "parses a single-line error" do+testErrors :: [TestTree]+testErrors =+ [ testCase "parses a single-line error" do let input = ["src/Foo.hs:10:5: error: Variable not in scope: foo"] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Foo.hs"- , startPos = Position 10 5- , endPos = Position 10 5- , messageLines = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]- }- ]-- it "parses a warning with continuation lines" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Foo.hs"+ , startPos = Position 10 5+ , endPos = Position 10 5+ , messageLines = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]+ }+ ]+ , testCase "parses a warning with continuation lines" do let input = [ "src/Bar.hs:20:3: warning: [-Wunused-imports]" , " Redundant import: Data.List" , " Perhaps you want to remove it." ] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GWarning- , file = "src/Bar.hs"- , startPos = Position 20 3- , endPos = Position 20 3- , messageLines =- [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"- , " Redundant import: Data.List"- , " Perhaps you want to remove it."- ]- }- ]-- it "parses a span position (L:C-C2:)" do+ @?= [ GMessage+ GhciMessage+ { severity = GWarning+ , file = "src/Bar.hs"+ , startPos = Position 20 3+ , endPos = Position 20 3+ , messageLines =+ [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"+ , " Redundant import: Data.List"+ , " Perhaps you want to remove it."+ ]+ }+ ]+ , testCase "parses a span position (L:C-C2:)" do let input = ["src/Baz.hs:5:1-10: error: Parse error"] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Baz.hs"- , startPos = Position 5 1- , endPos = Position 5 10- , messageLines = ["src/Baz.hs:5:1-10: error: Parse error"]- }- ]-- it "parses a span position ((L1,C1)-(L2,C2):)" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Baz.hs"+ , startPos = Position 5 1+ , endPos = Position 5 10+ , messageLines = ["src/Baz.hs:5:1-10: error: Parse error"]+ }+ ]+ , testCase "parses a span position ((L1,C1)-(L2,C2):)" do let input = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Qux.hs"- , startPos = Position 3 1- , endPos = Position 5 20- , messageLines = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]- }- ]-- it "parses a span position with double-paren end ((L1,C1)-((L2,C2):)" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Qux.hs"+ , startPos = Position 3 1+ , endPos = Position 5 20+ , messageLines = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]+ }+ ]+ , testCase "parses a span position with double-paren end ((L1,C1)-((L2,C2):)" do let input = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Qux.hs"- , startPos = Position 3 1- , endPos = Position 5 20- , messageLines = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]- }- ]-- it "parses source-display continuation lines (pipe format)" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Qux.hs"+ , startPos = Position 3 1+ , endPos = Position 5 20+ , messageLines = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]+ }+ ]+ , testCase "parses source-display continuation lines (pipe format)" do let input = [ "src/Foo.hs:10:5: error: Variable not in scope: foo" , " |"@@ -175,49 +180,46 @@ , " Suggested fix: import Foo" ] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Foo.hs"- , startPos = Position 10 5- , endPos = Position 10 5- , messageLines =- [ "src/Foo.hs:10:5: error: Variable not in scope: foo"- , " |"- , "10 | foo bar"- , " | ^^^"- , " Suggested fix: import Foo"- ]- }- ]-- it "strips ANSI codes from header for matching but stores original in glMessage" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Foo.hs"+ , startPos = Position 10 5+ , endPos = Position 10 5+ , messageLines =+ [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+ , " |"+ , "10 | foo bar"+ , " | ^^^"+ , " Suggested fix: import Foo"+ ]+ }+ ]+ , testCase "strips ANSI codes from header for matching but stores original in glMessage" do let ansiHeader = "\ESC[1msrc/Foo.hs:10:5:\ESC[0m \ESC[91merror:\ESC[0m Variable not in scope: foo" parseReload [ansiHeader]- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Foo.hs"- , startPos = Position 10 5- , endPos = Position 10 5- , messageLines = [ansiHeader]- }- ]-- it "parses a Windows drive-letter path in a diagnostic" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Foo.hs"+ , startPos = Position 10 5+ , endPos = Position 10 5+ , messageLines = [ansiHeader]+ }+ ]+ , testCase "parses a Windows drive-letter path in a diagnostic" do let input = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "C:\\path\\file.hs"- , startPos = Position 10 5- , endPos = Position 10 5- , messageLines = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]- }- ]-- it "parses mixed Loading, Message, and summary items" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "C:\\path\\file.hs"+ , startPos = Position 10 5+ , endPos = Position 10 5+ , messageLines = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]+ }+ ]+ , testCase "parses mixed Loading, Message, and summary items" do let input = [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )" , "src/Lib.hs:5:1: error: Oops"@@ -225,185 +227,185 @@ , "Failed, 1 module loaded." ] parseReload input- `shouldBe` [ GLoading GhciLoading {index = 1, total = 2, moduleName = "Lib", sourceFile = "src/Lib.hs"}- , GMessage- GhciMessage- { severity = GError- , file = "src/Lib.hs"- , startPos = Position 5 1- , endPos = Position 5 1- , messageLines = ["src/Lib.hs:5:1: error: Oops"]- }- , GLoading GhciLoading {index = 2, total = 2, moduleName = "Main", sourceFile = "app/Main.hs"}- , GSummary LoadFailed- ]+ @?= [ GLoading GhciLoading {index = 1, total = 2, moduleName = "Lib", sourceFile = "src/Lib.hs"}+ , GMessage+ GhciMessage+ { severity = GError+ , file = "src/Lib.hs"+ , startPos = Position 5 1+ , endPos = Position 5 1+ , messageLines = ["src/Lib.hs:5:1: error: Oops"]+ }+ , GLoading GhciLoading {index = 2, total = 2, moduleName = "Main", sourceFile = "app/Main.hs"}+ , GSummary LoadFailed+ ]+ ] -------------------------------------------------------------------------------- -- parseReload: -fhide-source-paths output -------------------------------------------------------------------------------- -testHideSourcePaths :: Spec-testHideSourcePaths = do- describe "when source paths are hidden" $ it "produces no GLoading items" do- let input =- [ "src/Foo.hs:10:5: error: Variable not in scope: foo"- , " Perhaps you meant: 'bar'"- , "Failed, one module failed to load."- ]- parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "src/Foo.hs"- , startPos = Position 10 5- , endPos = Position 10 5- , messageLines =- [ "src/Foo.hs:10:5: error: Variable not in scope: foo"- , " Perhaps you meant: 'bar'"- ]- }- , GSummary LoadFailed- ]-- describe "when all modules are already up to date" $ it "returns the summary outcome" do- -- GHCi with -fhide-source-paths and nothing to recompile- parseReload ["Ok, 5 modules loaded."] `shouldBe` [GSummary LoadSucceeded]+testHideSourcePaths :: [TestTree]+testHideSourcePaths =+ [ testGroup+ "when source paths are hidden"+ [ testCase "produces no GLoading items" do+ let input =+ [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+ , " Perhaps you meant: 'bar'"+ , "Failed, one module failed to load."+ ]+ parseReload input+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "src/Foo.hs"+ , startPos = Position 10 5+ , endPos = Position 10 5+ , messageLines =+ [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+ , " Perhaps you meant: 'bar'"+ ]+ }+ , GSummary LoadFailed+ ]+ ]+ , testGroup+ "when all modules are already up to date"+ [ testCase "returns the summary outcome" do+ -- GHCi with -fhide-source-paths and nothing to recompile+ parseReload ["Ok, 5 modules loaded."] @?= [GSummary LoadSucceeded]+ ]+ ] -------------------------------------------------------------------------------- -- parseReload: <no location info> errors -------------------------------------------------------------------------------- -testNoLocationInfo :: Spec-testNoLocationInfo = do- it "handles <no location info>: error: with continuation" do+testNoLocationInfo :: [TestTree]+testNoLocationInfo =+ [ testCase "handles <no location info>: error: with continuation" do let input = [ "<no location info>: error:" , " Module `Tricorder.Missing' is not loaded." ] parseReload input- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "<no location info>"- , startPos = Position 0 0- , endPos = Position 0 0- , messageLines =- [ "<no location info>: error:"- , " Module `Tricorder.Missing' is not loaded."- ]- }- ]-- it "handles <no location info>: error: with no continuation" do+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "<no location info>"+ , startPos = Position 0 0+ , endPos = Position 0 0+ , messageLines =+ [ "<no location info>: error:"+ , " Module `Tricorder.Missing' is not loaded."+ ]+ }+ ]+ , testCase "handles <no location info>: error: with no continuation" do parseReload ["<no location info>: error: some error"]- `shouldBe` [ GMessage- GhciMessage- { severity = GError- , file = "<no location info>"- , startPos = Position 0 0- , endPos = Position 0 0- , messageLines = ["<no location info>: error: some error"]- }- ]+ @?= [ GMessage+ GhciMessage+ { severity = GError+ , file = "<no location info>"+ , startPos = Position 0 0+ , endPos = Position 0 0+ , messageLines = ["<no location info>: error: some error"]+ }+ ]+ ] -------------------------------------------------------------------------------- -- parseReload: Loaded GHCi configuration -------------------------------------------------------------------------------- -testLoadedConfig :: Spec-testLoadedConfig = do- it "parses a GHCi configuration line" do+testLoadedConfig :: [TestTree]+testLoadedConfig =+ [ testCase "parses a GHCi configuration line" do parseReload ["Loaded GHCi configuration from /home/user/project/.ghci"]- `shouldBe` [GLoadConfig "/home/user/project/.ghci"]-- it "parses a Windows-style GHCi configuration path" do+ @?= [GLoadConfig "/home/user/project/.ghci"]+ , testCase "parses a Windows-style GHCi configuration path" do parseReload ["Loaded GHCi configuration from C:\\Users\\user\\project\\.ghci"]- `shouldBe` [GLoadConfig "C:\\Users\\user\\project\\.ghci"]-- it "handles config line mixed with other output" do+ @?= [GLoadConfig "C:\\Users\\user\\project\\.ghci"]+ , testCase "handles config line mixed with other output" do let input = [ "Loaded GHCi configuration from .ghci" , "[1 of 1] Compiling Main ( app/Main.hs, interpreted )" , "Ok, 1 module loaded." ] parseReload input- `shouldBe` [ GLoadConfig ".ghci"- , GLoading GhciLoading {index = 1, total = 1, moduleName = "Main", sourceFile = "app/Main.hs"}- , GSummary LoadSucceeded- ]+ @?= [ GLoadConfig ".ghci"+ , GLoading GhciLoading {index = 1, total = 1, moduleName = "Main", sourceFile = "app/Main.hs"}+ , GSummary LoadSucceeded+ ]+ ] -------------------------------------------------------------------------------- -- parseShowModules -------------------------------------------------------------------------------- -testShowModules :: Spec-testShowModules = do- it "parses typical :show modules output" do+testShowModules :: [TestTree]+testShowModules =+ [ testCase "parses typical :show modules output" do let input = [ "Tricorder.Build ( src/Tricorder.Build.hs, interpreted )" , "Tricorder.Session ( src/Tricorder/Session.hs, interpreted )" , "Main ( app/Main.hs, interpreted )" ] parseShowModules input- `shouldBe` [ ("Tricorder.Build", "src/Tricorder.Build.hs")- , ("Tricorder.Session", "src/Tricorder/Session.hs")- , ("Main", "app/Main.hs")- ]-- it "parses absolute paths" do+ @?= [ ("Tricorder.Build", "src/Tricorder.Build.hs")+ , ("Tricorder.Session", "src/Tricorder/Session.hs")+ , ("Main", "app/Main.hs")+ ]+ , testCase "parses absolute paths" do parseShowModules ["Lib ( /home/user/project/src/Lib.hs, interpreted )"]- `shouldBe` [("Lib", "/home/user/project/src/Lib.hs")]-- it "strips ANSI codes before parsing" do+ @?= [("Lib", "/home/user/project/src/Lib.hs")]+ , testCase "strips ANSI codes before parsing" do parseShowModules ["\ESC[1mMain\ESC[0m ( app/Main.hs, interpreted )"]- `shouldBe` [("Main", "app/Main.hs")]+ @?= [("Main", "app/Main.hs")]+ ] -testShowModulesEmpty :: Spec-testShowModulesEmpty = do- it "returns empty list for empty input" do- parseShowModules [] `shouldBe` []-- it "returns empty list for blank lines" do- parseShowModules ["", " ", "\t"] `shouldBe` []-- it "skips lines without '( '" do- parseShowModules ["just some random text"] `shouldBe` []+testShowModulesEmpty :: [TestTree]+testShowModulesEmpty =+ [ testCase "returns empty list for empty input" do+ parseShowModules [] @?= []+ , testCase "returns empty list for blank lines" do+ parseShowModules ["", " ", "\t"] @?= []+ , testCase "skips lines without '( '" do+ parseShowModules ["just some random text"] @?= []+ ] -------------------------------------------------------------------------------- -- parseShowTargets -------------------------------------------------------------------------------- -testShowTargets :: Spec-testShowTargets = do- it "parses module names emitted by cabal repl --enable-multi-repl" do+testShowTargets :: [TestTree]+testShowTargets =+ [ testCase "parses module names emitted by cabal repl --enable-multi-repl" do parseShowTargets [ "Atelier.Effects.Cache" , "Atelier.Effects.Chan" , "Paths_tricorder" ]- `shouldBe` ["Atelier.Effects.Cache", "Atelier.Effects.Chan", "Paths_tricorder"]-- it "parses file-path targets emitted by plain ghci" do+ @?= ["Atelier.Effects.Cache", "Atelier.Effects.Chan", "Paths_tricorder"]+ , testCase "parses file-path targets emitted by plain ghci" do parseShowTargets ["src/Foo.hs", "test/Bar.hs"]- `shouldBe` ["src/Foo.hs", "test/Bar.hs"]-- it "strips the leading '*' marker for the active interactive target" do- parseShowTargets ["*Main", "Foo.Bar"] `shouldBe` ["Main", "Foo.Bar"]-- it "strips ANSI escape sequences" do- parseShowTargets ["\ESC[1mFoo.Bar\ESC[0m"] `shouldBe` ["Foo.Bar"]-- it "skips blank and whitespace-only lines" do- parseShowTargets ["", " ", "\t", "Real.Target"] `shouldBe` ["Real.Target"]-- it "returns empty list for empty input" do- parseShowTargets [] `shouldBe` []+ @?= ["src/Foo.hs", "test/Bar.hs"]+ , testCase "strips the leading '*' marker for the active interactive target" do+ parseShowTargets ["*Main", "Foo.Bar"] @?= ["Main", "Foo.Bar"]+ , testCase "strips ANSI escape sequences" do+ parseShowTargets ["\ESC[1mFoo.Bar\ESC[0m"] @?= ["Foo.Bar"]+ , testCase "skips blank and whitespace-only lines" do+ parseShowTargets ["", " ", "\t", "Real.Target"] @?= ["Real.Target"]+ , testCase "returns empty list for empty input" do+ parseShowTargets [] @?= []+ ] --------------------------------------------------------------------------------@@ -418,30 +420,31 @@ -- failure with @\<no location info\>@ (it has no source span), and the load -- ends with @Failed, N modules loaded@. This output must still surface as an -- error diagnostic — otherwise the build is silently reported as clean.-testPluginLoadFailure :: Spec-testPluginLoadFailure = do+testPluginLoadFailure :: [TestTree]+testPluginLoadFailure = -- Shape of the GHCi output from `cabal repl --enable-multi-repl` when an -- executable loads a home-unit GHC plugin: the plugin package's modules -- compile, then the unit using the plugin fails with a location-less error.- let reloadOutput =- [ "[3 of 5] Compiling My.Plugin ( src/My/Plugin.hs, interpreted )[plugin-pkg-1.0.0-inplace]"- , "<no location info>: error:"- , " Could not load module \8216My.Plugin\8217."- , "It is a member of the hidden package \8216plugin-pkg-1.0.0\8217."- , "Perhaps you need to add \8216plugin-pkg\8217 to the build-depends in your .cabal file."- , "Use -v to see a list of the files searched for."- , ""- , "[5 of 5] Compiling Main ( test/Tests.hs, interpreted )[app-pkg-0.1.0.0-inplace-test]"- , "Failed, 4 modules loaded."- ]- result = collectResultCustom "/project" (parseReload reloadOutput) [] []-- it "surfaces the plugin load failure as an error diagnostic" do- map (.severity) result.diagnostics `shouldContain` [SError]-- it "carries the plugin error message in the diagnostic title" do- map (.title) result.diagnostics- `shouldContain` ["Could not load module \8216My.Plugin\8217."]+ [ testCase "surfaces the plugin load failure as an error diagnostic" do+ assertBool "expected an error diagnostic"+ $ SError `elem` map (.severity) result.diagnostics+ , testCase "carries the plugin error message in the diagnostic title" do+ assertBool "expected the plugin error message in a diagnostic title"+ $ "Could not load module \8216My.Plugin\8217." `elem` map (.title) result.diagnostics+ ]+ where+ reloadOutput =+ [ "[3 of 5] Compiling My.Plugin ( src/My/Plugin.hs, interpreted )[plugin-pkg-1.0.0-inplace]"+ , "<no location info>: error:"+ , " Could not load module \8216My.Plugin\8217."+ , "It is a member of the hidden package \8216plugin-pkg-1.0.0\8217."+ , "Perhaps you need to add \8216plugin-pkg\8217 to the build-depends in your .cabal file."+ , "Use -v to see a list of the files searched for."+ , ""+ , "[5 of 5] Compiling Main ( test/Tests.hs, interpreted )[app-pkg-0.1.0.0-inplace-test]"+ , "Failed, 4 modules loaded."+ ]+ result = collectResultCustom "/project" (parseReload reloadOutput) [] [] --------------------------------------------------------------------------------@@ -451,18 +454,20 @@ -- 'collectResult' is the safety net: GHCi can end a load with @Failed, …@ -- without emitting any error that carries a source span. The build must never -- read as clean in that case, so a synthetic error diagnostic is added.-testUnattributedFailure :: Spec-testUnattributedFailure = do- describe "when the load failed but no error was located" $ it "adds a synthetic error" do- let reloadOutput =- [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )"- , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"- , "Failed, 1 module loaded."- ]- result = collectResult "/project" reloadOutput [] []- map (.severity) result.diagnostics `shouldBe` [SError]-- it "does not duplicate a failure that already produced a located error" do+testUnattributedFailure :: [TestTree]+testUnattributedFailure =+ [ testGroup+ "when the load failed but no error was located"+ [ testCase "adds a synthetic error" do+ let reloadOutput =+ [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )"+ , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"+ , "Failed, 1 module loaded."+ ]+ result = collectResult "/project" reloadOutput [] []+ map (.severity) result.diagnostics @?= [SError]+ ]+ , testCase "does not duplicate a failure that already produced a located error" do let reloadOutput = [ "[1 of 1] Compiling Lib ( src/Lib.hs, interpreted )" , "src/Lib.hs:5:1: error: Oops"@@ -470,27 +475,29 @@ ] result = collectResult "/project" reloadOutput [] [] -- Only the real, located diagnostic — no synthetic one appended.- map (.file) result.diagnostics `shouldBe` ["src/Lib.hs"]-- it "adds nothing for a successful load" do+ map (.file) result.diagnostics @?= ["src/Lib.hs"]+ , testCase "adds nothing for a successful load" do let reloadOutput = [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )" , "Ok, 1 module loaded." ] result = collectResult "/project" reloadOutput [] []- result.diagnostics `shouldBe` []-- describe "when 'Failed,' appears off the summary line" $ it "does not flag a clean build" do- -- The load outcome lives on GHCi's single summary line- -- ("Ok, …" / "Failed, …"). Output printed *during* the load — e.g. a- -- Template Haskell splice or top-level IO run while interpreting — can- -- contain a line that happens to begin with "Failed,". That must not be- -- mistaken for a failed load: the summary here is "Ok," so no synthetic- -- error belongs.- let reloadOutput =- [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"- , "Failed, retrying with fallback" -- printed by a TH splice- , "Ok, 1 module loaded."- ]- result = collectResult "/project" reloadOutput [] []- result.diagnostics `shouldBe` []+ result.diagnostics @?= []+ , testGroup+ "when 'Failed,' appears off the summary line"+ [ testCase "does not flag a clean build" do+ -- The load outcome lives on GHCi's single summary line+ -- ("Ok, …" / "Failed, …"). Output printed *during* the load — e.g. a+ -- Template Haskell splice or top-level IO run while interpreting — can+ -- contain a line that happens to begin with "Failed,". That must not be+ -- mistaken for a failed load: the summary here is "Ok," so no synthetic+ -- error belongs.+ let reloadOutput =+ [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"+ , "Failed, retrying with fallback" -- printed by a TH splice+ , "Ok, 1 module loaded."+ ]+ result = collectResult "/project" reloadOutput [] []+ result.diagnostics @?= []+ ]+ ]
test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs view
@@ -1,4 +1,4 @@-module Unit.Tricorder.Daemon.GhciSession.GhciProcessSpec (spec_GhciProcess) where+module Unit.Tricorder.Daemon.GhciSession.GhciProcessSpec (test_GhciProcess) where import Atelier.Effects.Conc (runConc) import Atelier.Effects.Delay (runDelay)@@ -34,7 +34,8 @@ , stopProcess , waitExitCode )-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=)) import Atelier.Effects.Conc qualified as Conc import Atelier.Effects.Delay qualified as Delay@@ -49,6 +50,7 @@ , GhciProcessError (..) , InterruptDecision (..) , SessionState (..)+ , UnexpectedExit (..) , decideInterrupt , drainUntil , execGhci@@ -56,16 +58,19 @@ ) -spec_GhciProcess :: Spec-spec_GhciProcess = do- describe "decideInterrupt" testDecideInterrupt- describe "drainUntil" testDrainUntil- describe "execGhci" testExecGhciScope- describe "execGhci (stale marker desync)" testExecGhciStaleMarker- describe "execGhci (sync marker scope independence)" testSyncMarkerScopeIndependent- describe "waitForBannerOrFail" testWaitForBannerOrFail- describe "withProcessGroup (process group)" testWithProcessGroupCleanup- describe "terminateProcessGroup (process group)" testTerminateProcessGroup+test_GhciProcess :: TestTree+test_GhciProcess =+ testGroup+ "GhciProcess"+ [ testGroup "decideInterrupt" testDecideInterrupt+ , testGroup "drainUntil" testDrainUntil+ , testGroup "execGhci" testExecGhciScope+ , testGroup "execGhci (stale marker desync)" testExecGhciStaleMarker+ , testGroup "execGhci (sync marker scope independence)" testSyncMarkerScopeIndependent+ , testGroup "waitForBannerOrFail" testWaitForBannerOrFail+ , testGroup "withProcessGroup (process group)" testWithProcessGroupCleanup+ , testGroup "terminateProcessGroup (process group)" testTerminateProcessGroup+ ] -- | Mirrors the private 'markerFor' helper (not exported), using the same@@ -74,9 +79,9 @@ finishMarker n = "#~TRI-FINISH-" <> show n <> "~#" -testDrainUntil :: Spec-testDrainUntil = do- it "returns accumulated non-marker lines in order and stops at the marker" do+testDrainUntil :: [TestTree]+testDrainUntil =+ [ testCase "returns accumulated non-marker lines in order and stops at the marker" do (r, w) <- Process.createPipe (result, _msgs) <- runEff@@ -87,9 +92,8 @@ for_ ["line1", "line2", finishMarker 1, "line3"] (File.hPutTextLn w) File.hClose w drainUntil r (finishMarker 1) (\_ -> pure ())- result `shouldBe` ["line1", "line2"]-- it "streams each non-marker line to onLine, in order, before returning" do+ result @?= ["line1", "line2"]+ , testCase "streams each non-marker line to onLine, in order, before returning" do (r, w) <- Process.createPipe seenRef <- newIORef [] (result, _msgs) <-@@ -102,10 +106,9 @@ File.hClose w drainUntil r (finishMarker 2) (\l -> liftIO $ modifyIORef' seenRef (l :)) seen <- reverse <$> readIORef seenRef- seen `shouldBe` ["a", "b", "c"]- result `shouldBe` seen-- it "skips a stale marker with a different suffix and keeps draining" do+ seen @?= ["a", "b", "c"]+ result @?= seen+ , testCase "skips a stale marker with a different suffix and keeps draining" do (r, w) <- Process.createPipe (result, _msgs) <- runEff@@ -119,28 +122,29 @@ for_ ["before", finishMarker 5, "after", finishMarker 9] (File.hPutTextLn w) File.hClose w drainUntil r (finishMarker 9) (\_ -> pure ())- result `shouldBe` ["before", "after"]-- it "throws UnexpectedExit with ALL accumulated lines (in order) on EOF, not just the last one" do- (r, w) <- Process.createPipe- (outcome, _msgs) <-- runEff- . runWriter @[Message]- . runLogWriter- . runFile- $ do- for_ ["first", "second", "third"] (File.hPutTextLn w)- File.hClose w -- EOF before the marker ever arrives- trySync (drainUntil r (finishMarker 1) (\_ -> pure ()))- case outcome of- Right ls -> expectationFailure ("expected UnexpectedExit, got: " <> show ls)- Left ex -> case fromException ex of- Just (UnexpectedExit m ls) -> do- m `shouldBe` finishMarker 1- ls `shouldBe` Just "first\nsecond\nthird"- other -> expectationFailure ("expected UnexpectedExit, got: " <> show other)-- it "throws UnexpectedExit with no lines when EOF is reached immediately" do+ result @?= ["before", "after"]+ , testCase+ "throws UnexpectedExit with ALL accumulated lines (in order) on EOF, not just the last one"+ do+ (r, w) <- Process.createPipe+ (outcome, _msgs) <-+ runEff+ . runWriter @[Message]+ . runLogWriter+ . runFile+ $ do+ for_ ["first", "second", "third"] (File.hPutTextLn w)+ File.hClose w -- EOF before the marker ever arrives+ trySync (drainUntil r (finishMarker 1) (\_ -> pure ()))+ case outcome of+ Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)+ Left ex -> case fromException ex of+ Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do+ m @?= finishMarker 1+ ls+ @?= "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nAccumulated output from GHCi so far:\nfirst\nsecond\nthird"+ other -> assertFailure ("expected UnexpectedExit, got: " <> show other)+ , testCase "throws UnexpectedExit with no lines when EOF is reached immediately" do (r, w) <- Process.createPipe (outcome, _msgs) <- runEff@@ -151,14 +155,20 @@ File.hClose w trySync (drainUntil r (finishMarker 1) (\_ -> pure ())) case outcome of- Right ls -> expectationFailure ("expected UnexpectedExit, got: " <> show ls)+ Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls) Left ex -> case fromException ex of- Just (UnexpectedExit m ls) -> do- m `shouldBe` finishMarker 1- ls `shouldBe` Nothing- other -> expectationFailure ("expected UnexpectedExit, got: " <> show other)-- it+ Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do+ m @?= finishMarker 1+ let expected =+ "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nGHCi returned no output before we reached what we believe is EOF.\nGot the following exception when attempting to read from GHCi:\n<file descriptor:"+ (expected `T.isPrefixOf` ls)+ @? ( "error message does not start with expected prefix\nExpected:\n"+ <> show expected+ <> "\nActual:\n"+ <> show ls+ )+ other -> assertFailure ("expected UnexpectedExit, got: " <> show other)+ , testCase "logs an ERROR mentioning the missing marker and the exception when EOF is reached with no output" do (r, w) <- Process.createPipe@@ -171,12 +181,11 @@ File.hClose w trySync (drainUntil r (finishMarker 3) (\_ -> pure ())) case filter (\m -> m.severity == ERROR) msgs of- [] -> expectationFailure "expected an ERROR log message"+ [] -> assertFailure "expected an ERROR log message" (logMsg : _) -> do- (finishMarker 3 `T.isInfixOf` logMsg.text) `shouldBe` True- ("GHCi returned no output" `T.isInfixOf` logMsg.text) `shouldBe` True-- it "logs an ERROR including the accumulated output when EOF is reached mid-output" do+ (finishMarker 3 `T.isInfixOf` logMsg.text) @?= True+ ("GHCi returned no output" `T.isInfixOf` logMsg.text) @?= True+ , testCase "logs an ERROR including the accumulated output when EOF is reached mid-output" do (r, w) <- Process.createPipe (_outcome, msgs) <- runEff@@ -188,11 +197,12 @@ File.hClose w trySync (drainUntil r (finishMarker 4) (\_ -> pure ())) case filter (\m -> m.severity == ERROR) msgs of- [] -> expectationFailure "expected an ERROR log message"+ [] -> assertFailure "expected an ERROR log message" (logMsg : _) -> do- (finishMarker 4 `T.isInfixOf` logMsg.text) `shouldBe` True- ("oops-line-1" `T.isInfixOf` logMsg.text) `shouldBe` True- ("oops-line-2" `T.isInfixOf` logMsg.text) `shouldBe` True+ (finishMarker 4 `T.isInfixOf` logMsg.text) @?= True+ ("oops-line-1" `T.isInfixOf` logMsg.text) @?= True+ ("oops-line-2" `T.isInfixOf` logMsg.text) @?= True+ ] -- | Regression for the touch-during-reload desync. Interrupting a *Busy* GHCi@@ -202,9 +212,9 @@ -- returned *before its command ran* — surfacing as @All good. (0 modules)@ (or, -- on the other timing, a hang). 'execGhci' must skip markers that aren't its -- own and stop only on the marker it is waiting for.-testExecGhciStaleMarker :: Spec+testExecGhciStaleMarker :: [TestTree] testExecGhciStaleMarker =- it "skips a stale leftover marker and returns the command's real output" do+ [ testCase "skips a stale leftover marker and returns the command's real output" do (stdinR, stdinW) <- Process.createPipe (stdoutR, stdoutW) <- Process.createPipe (stderrR, stderrW) <- Process.createPipe@@ -249,7 +259,8 @@ File.hClose stdinR pure r _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())- result `shouldBe` ["out-line", "err-line"]+ result @?= ["out-line", "err-line"]+ ] -- | Root-cause regression for the "stuck Building…" stall. A SIGINT-interrupted@@ -262,9 +273,9 @@ -- emptied scope), one per stream, with no bare names or operators. -- -- We assert on exactly what 'execGhci' writes to GHCi's stdin.-testSyncMarkerScopeIndependent :: Spec+testSyncMarkerScopeIndependent :: [TestTree] testSyncMarkerScopeIndependent =- it "writes the finish marker using only fully-qualified names (no bare putStrLn / >>)" do+ [ testCase "writes the finish marker using only fully-qualified names (no bare putStrLn / >>)" do (stdinR, stdinW) <- Process.createPipe (stdoutR, stdoutW) <- Process.createPipe (stderrR, stderrW) <- Process.createPipe@@ -309,9 +320,10 @@ readAll [] _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ()) let blob = T.intercalate "\n" written- (" >> " `T.isInfixOf` blob) `shouldBe` False- ("System.IO.hPutStrLn System.IO.stdout" `T.isInfixOf` blob) `shouldBe` True- ("System.IO.hPutStrLn System.IO.stderr" `T.isInfixOf` blob) `shouldBe` True+ (" >> " `T.isInfixOf` blob) @?= False+ ("System.IO.hPutStrLn System.IO.stdout" `T.isInfixOf` blob) @?= True+ ("System.IO.hPutStrLn System.IO.stderr" `T.isInfixOf` blob) @?= True+ ] -- | Regression: when the build command exits before printing a GHCi banner,@@ -320,9 +332,9 @@ -- lines as soon as the process exited (it waited on 'waitExitCode'), racing -- the concurrent stderr drain — so a burst of error lines still buffered in -- the pipe was truncated, and the real cabal/build failure was lost.-testWaitForBannerOrFail :: Spec+testWaitForBannerOrFail :: [TestTree] testWaitForBannerOrFail =- it "captures the full stderr output when the command exits before the banner" do+ [ testCase "captures the full stderr output when the command exits before the banner" do let lineCount = 200 :: Int lastLine = "err line " <> show lineCount -- The banner and error streams are pipes we drive ourselves, so the@@ -352,12 +364,13 @@ File.hClose errW trySync (waitForBannerOrFail (5 :: Second) bannerOut errR) case result of- Right () -> expectationFailure "expected waitForBannerOrFail to throw a startup error"+ Right () -> assertFailure "expected waitForBannerOrFail to throw a startup error" Left ex -> case fromException ex of Just (StartupFailed msg) ->- (lastLine `T.isInfixOf` msg) `shouldBe` True+ (lastLine `T.isInfixOf` msg) @?= True other ->- expectationFailure ("expected StartupFailed, got: " <> show other)+ assertFailure ("expected StartupFailed, got: " <> show other)+ ] -- | Regression for orphaned/zombie build subprocesses on restart and shutdown.@@ -368,9 +381,9 @@ -- gone. We simulate it with a leader that forks a long-lived child sharing its -- group and exits on stdin input (mirroring @:quit@); after 'withProcessGroup' -- returns, the child must be gone.-testWithProcessGroupCleanup :: Spec+testWithProcessGroupCleanup :: [TestTree] testWithProcessGroupCleanup =- it "terminates the whole group on exit, even after the leader has exited" do+ [ testCase "terminates the whole group on exit, even after the leader has exited" do childPidRef <- newIORef (Nothing :: Maybe Int) let scenario = runEff@@ -393,15 +406,16 @@ -- assertion, never as a hang that stalls the whole suite. outcome <- System.Timeout.timeout (8_000_000) scenario case outcome of- Nothing -> expectationFailure "test timed out (process did not settle)"+ Nothing -> assertFailure "test timed out (process did not settle)" Just () -> readIORef childPidRef >>= \case- Nothing -> expectationFailure "could not capture the child pid"+ Nothing -> assertFailure "could not capture the child pid" Just childPid -> do died <- waitForProcessDeath childPid -- Never leak the child if the assertion fails. ignoring (signalProcess sigKILL (fromIntegral childPid))- died `shouldBe` True+ died @?= True+ ] where procConfig = setStdin createPipe@@ -413,9 +427,9 @@ -- | 'terminateProcessGroup' must kill the whole group when called mid-flight -- (the leader still alive) — the explicit early-termination path the test -- runner uses to abort a one-shot @cabal repl test:…@ from another thread.-testTerminateProcessGroup :: Spec+testTerminateProcessGroup :: [TestTree] testTerminateProcessGroup =- it "kills the whole group, not just the leader, mid-flight" do+ [ testCase "kills the whole group, not just the leader, mid-flight" do outcome <- System.Timeout.timeout (8_000_000) do p <- startProcess@@ -428,15 +442,15 @@ case parsePid childLine of Nothing -> do ignoring (stopProcess p)- expectationFailure ("could not parse child pid from: " <> show childLine)- pure False+ assertFailure ("could not parse child pid from: " <> show childLine) Just childPid -> do runEff . runProcessIO $ terminateProcessGroup (RunningProcess p) died <- waitForProcessDeath childPid -- Never leak the child if the assertion fails. ignoring (signalProcess sigKILL (fromIntegral childPid)) pure died- outcome `shouldBe` Just True+ outcome @?= Just True+ ] -- | Parse a pid printed on its own line (tolerating surrounding whitespace).@@ -464,24 +478,22 @@ `catch` \(_ :: IOException) -> pure False -testDecideInterrupt :: Spec-testDecideInterrupt = do+testDecideInterrupt :: [TestTree]+testDecideInterrupt = -- Regression: an idle GHCi must not be SIGINT'd, since the matching -- sync-marker write would leave a stale marker line in stdout/stderr -- that the next 'execGhci' drain would match instead of the fresh one, -- desyncing the protocol and reporting "0 modules" or hanging.- it "is a no-op when the session is Idle" do- decideInterrupt (Idle 7) `shouldBe` (Idle 7, NoOpIdle)-- it "preserves the counter for any Idle state" do- decideInterrupt (Idle 0) `shouldBe` (Idle 0, NoOpIdle)- decideInterrupt (Idle 42) `shouldBe` (Idle 42, NoOpIdle)-- it "advances to Idle (n+1) and emits SendInterruptFor n when Busy" do- decideInterrupt (Busy 7) `shouldBe` (Idle 8, SendInterruptFor 7)-- it "advances correctly from Busy 0" do- decideInterrupt (Busy 0) `shouldBe` (Idle 1, SendInterruptFor 0)+ [ testCase "is a no-op when the session is Idle" do+ decideInterrupt (Idle 7) @?= (Idle 7, NoOpIdle)+ , testCase "preserves the counter for any Idle state" do+ decideInterrupt (Idle 0) @?= (Idle 0, NoOpIdle)+ decideInterrupt (Idle 42) @?= (Idle 42, NoOpIdle)+ , testCase "advances to Idle (n+1) and emits SendInterruptFor n when Busy" do+ decideInterrupt (Busy 7) @?= (Idle 8, SendInterruptFor 7)+ , testCase "advances correctly from Busy 0" do+ decideInterrupt (Busy 0) @?= (Idle 1, SendInterruptFor 0)+ ] -- | Pins down the 'Conc.scoped' fix in 'execGhci': when the drain forks@@ -496,13 +508,13 @@ -- ambient scope is torn down — siblings die, the whole builder cycle -- unwinds, and the daemon ends up in the "Restarting builder..." state -- the user observed.-testExecGhciScope :: Spec+testExecGhciScope :: [TestTree] testExecGhciScope = -- Spawn a real subprocess that exits immediately ('true'). Its -- stdout/stderr pipes EOF as soon as the child exits, which makes -- 'drainUntil' inside 'execGhci' throw 'UnexpectedExit' — exactly the -- mid-command termination path the fix exists to handle.- it "contains drain exceptions inside its own scope so siblings survive" do+ [ testCase "contains drain exceptions inside its own scope so siblings survive" do p <- startProcess $ setStdin createPipe@@ -550,7 +562,8 @@ -- subprocess has long since exited; just swallow the cleanup error. _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ()) siblingDone <- readIORef siblingDoneRef- siblingDone `shouldBe` True+ siblingDone @?= True case result of+ Right _ -> assertFailure "expected execGhci to raise UnexpectedExit" Left _ -> pure ()- Right _ -> expectationFailure "expected execGhci to raise UnexpectedExit"+ ]
test/Unit/Tricorder/Daemon/GhciSessionSpec.hs view
@@ -1,10 +1,11 @@-module Unit.Tricorder.Daemon.GhciSessionSpec (spec_GhciSession) where+module Unit.Tricorder.Daemon.GhciSessionSpec (test_GhciSession) where import Atelier.Effects.Publishing.Pub (Pub) import Control.Exception (ErrorCall (..)) import Effectful (IOE, runEff) import Effectful.Exception (try)-import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=)) import Atelier.Effects.Publishing.Pub qualified as Pub import Data.Map.Strict qualified as Map@@ -19,79 +20,86 @@ , withGhci ) import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command (Command (..), Repl (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.Stage (Stage (..)) -spec_GhciSession :: Spec-spec_GhciSession = do- describe "runGhciSessionScripted" testScripted+test_GhciSession :: TestTree+test_GhciSession =+ testGroup+ "GhciSession"+ [ testGroup "runGhciSessionScripted" testScripted+ ] -------------------------------------------------------------------------------- -- Scripted interpreter tests -------------------------------------------------------------------------------- -testScripted :: Spec-testScripted = do- describe "withGhci" do- describe "initial load" do- it "returns scripted messages" do+testScripted :: [TestTree]+testScripted =+ [ testGroup+ "withGhci"+ [ testGroup+ "initial load"+ [ testCase "returns scripted messages" do LoadResult {diagnostics = msgs} <- runScripted [simpleResult [errMsg]] $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial- msgs `shouldBe` [errMsg]-- it "returns empty list when scripted result has no messages" do+ msgs @?= [errMsg]+ , testCase "returns empty list when scripted result has no messages" do LoadResult {diagnostics = msgs} <- runScripted [simpleResult []] $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial- msgs `shouldBe` []-- it "throws when scripted result is Left" do+ msgs @?= []+ , testCase "throws when scripted result is Left" do result <- runScripted [Left (toException boom)] $ try @ErrorCall $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial- result `shouldBe` Left boom-- describe "reloading" do- it "returns scripted messages" do+ result @?= Left boom+ ]+ , testGroup+ "reloading"+ [ testCase "returns scripted messages" do LoadResult {diagnostics = msgs} <- runScripted [simpleResult [warnMsg], simpleResult [errMsg]] $ withGhci cmd (ProjectRoot "/") \_ controls -> controls.reload- msgs `shouldBe` [errMsg]-- it "throws when scripted result is Left" do+ msgs @?= [errMsg]+ , testCase "throws when scripted result is Left" do result <- runScripted [Left (toException boom)] $ try @ErrorCall $ withGhci cmd (ProjectRoot "/") \_ controls -> controls.reload- result `shouldBe` Left boom-- describe "sequencing" do- it "consumes results in order across mixed operations" do+ result @?= Left boom+ ]+ ]+ , testGroup+ "sequencing"+ [ testCase "consumes results in order across mixed operations" do (a, b) <- runScripted [simpleResult [errMsg], simpleResult [warnMsg]] do withGhci cmd (ProjectRoot "/") \LoadResult {diagnostics = a} controls -> do LoadResult {diagnostics = b} <- controls.reload pure (a, b)- a `shouldBe` [errMsg]- b `shouldBe` [warnMsg]-- it "recover scenario: error then success" do+ a @?= [errMsg]+ b @?= [warnMsg]+ , testCase "recover scenario: error then success" do result <- runScripted [Left (toException boom), simpleResult []] do r1 <- try @ErrorCall $ withGhci cmd (ProjectRoot "/") \i _ -> pure i LoadResult {diagnostics = r2} <- withGhci cmd (ProjectRoot "/") \i _ -> pure i pure (r1, r2)- fst result `shouldSatisfy` isLeft- snd result `shouldBe` []+ assertBool "expected Left" $ isLeft (fst result)+ snd result @?= []+ ]+ ] -------------------------------------------------------------------------------- -- Helpers -------------------------------------------------------------------------------- -cmd :: Command-cmd = Command Cabal [] []+cmd :: RenderedCommand 'Build+cmd = RenderedCommand "cabal repl lib:foo" boom :: ErrorCall
test/Unit/Tricorder/Daemon/TestRunnerSpec.hs view
@@ -1,10 +1,12 @@-module Unit.Tricorder.Daemon.TestRunnerSpec (spec_TestRunner) where+module Unit.Tricorder.Daemon.TestRunnerSpec (test_TestRunner) where import Control.Exception (ErrorCall (..))+import Data.Default (def) import Effectful (IOE, runEff) import Effectful.Concurrent (Concurrent, runConcurrent) import Effectful.Exception (try)-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Daemon.TestRunner ( GhciOutcome (..)@@ -12,138 +14,156 @@ , detectOutcome , runTestSuite )-import Tricorder.Session.Command (Repl (..))-import Tricorder.Session.Target (Target (..))-import Tricorder.Session.TestTarget (TestTarget (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.Stage.Test.Command (RenderedTestCommand (..)) import Tricorder.Session.TestTimeout (TestTimeout (..)) import Tricorder.Build.Test qualified as Test import Tricorder.Daemon.TestRunner qualified as TestRunner+import Tricorder.Session.Stage qualified as Stage -spec_TestRunner :: Spec-spec_TestRunner = do- describe "detectOutcome" testDetectOutcome- describe "runScripted" testScripted+test_TestRunner :: TestTree+test_TestRunner =+ testGroup+ "TestRunner"+ [ testGroup "detectOutcome" testDetectOutcome+ , testGroup "runScripted" testScripted+ ] -------------------------------------------------------------------------------- -- detectOutcome tests -------------------------------------------------------------------------------- -testDetectOutcome :: Spec-testDetectOutcome = do- describe "no exception line" do- it "treats empty output as pass" do- detectOutcome "" `shouldBe` GhciPassed-- it "treats output with no exception as pass" do- detectOutcome "2 examples, 0 failures\n" `shouldBe` GhciPassed-- it "does not match 'ExitSuccess' without the exception prefix" do- detectOutcome "ExitSuccess\n" `shouldBe` GhciPassed-- describe "ExitSuccess" do- it "detects ExitSuccess as pass" do- detectOutcome "*** Exception: ExitSuccess\n" `shouldBe` GhciPassed-- it "detects ExitSuccess anywhere in output" do+testDetectOutcome :: [TestTree]+testDetectOutcome =+ [ testGroup+ "no exception line"+ [ testCase "treats empty output as pass" do+ detectOutcome "" @?= GhciPassed+ , testCase "treats output with no exception as pass" do+ detectOutcome "2 examples, 0 failures\n" @?= GhciPassed+ , testCase "does not match 'ExitSuccess' without the exception prefix" do+ detectOutcome "ExitSuccess\n" @?= GhciPassed+ ]+ , testGroup+ "ExitSuccess"+ [ testCase "detects ExitSuccess as pass" do+ detectOutcome "*** Exception: ExitSuccess\n" @?= GhciPassed+ , testCase "detects ExitSuccess anywhere in output" do detectOutcome "All tests passed\n*** Exception: ExitSuccess\n"- `shouldBe` GhciPassed-- describe "ExitFailure" do- it "detects ExitFailure 1 as fail" do+ @?= GhciPassed+ ]+ , testGroup+ "ExitFailure"+ [ testCase "detects ExitFailure 1 as fail" do detectOutcome "1 failure\n*** Exception: ExitFailure 1\n"- `shouldBe` GhciFailed-- it "detects ExitFailure with any exit code as fail" do- detectOutcome "*** Exception: ExitFailure 42\n" `shouldBe` GhciFailed-- it "detects ExitFailure anywhere in output" do+ @?= GhciFailed+ , testCase "detects ExitFailure with any exit code as fail" do+ detectOutcome "*** Exception: ExitFailure 42\n" @?= GhciFailed+ , testCase "detects ExitFailure anywhere in output" do detectOutcome "Some output\n*** Exception: ExitFailure 1\nMore output\n"- `shouldBe` GhciFailed-- describe "other exception" do- it "classifies unknown exception as error with message" do+ @?= GhciFailed+ ]+ , testGroup+ "other exception"+ [ testCase "classifies unknown exception as error with message" do detectOutcome "*** Exception: SomeException \"oops\"\n"- `shouldBe` GhciCrashed "SomeException \"oops\""-- it "trims trailing whitespace from the error message" do+ @?= GhciCrashed "SomeException \"oops\""+ , testCase "trims trailing whitespace from the error message" do detectOutcome "*** Exception: Crashed \n"- `shouldBe` GhciCrashed "Crashed"-- describe "compile failure (no exception line, but GHC errors present)" do- it "flags ':main not in scope' as crashed" do+ @?= GhciCrashed "Crashed"+ ]+ , testGroup+ "compile failure (no exception line, but GHC errors present)"+ [ testCase "flags ':main not in scope' as crashed" do detectOutcome "<interactive>:1:1: error: [GHC-76037] Not in scope: 'main'\n"- `shouldBe` GhciCrashed+ @?= GhciCrashed "<interactive>:1:1: error: [GHC-76037] Not in scope: 'main'"-- it "flags a source-file compile error as crashed" do+ , testCase "flags a source-file compile error as crashed" do detectOutcome "src/Foo.hs:42:5: error: Variable not in scope: foo\n"- `shouldBe` GhciCrashed "src/Foo.hs:42:5: error: Variable not in scope: foo"-- it "reports the first error line when multiple are present" do+ @?= GhciCrashed "src/Foo.hs:42:5: error: Variable not in scope: foo"+ , testCase "reports the first error line when multiple are present" do detectOutcome "src/Foo.hs:42:5: error: Variable not in scope: foo\nsrc/Bar.hs:10:1: error: Parse error\n"- `shouldBe` GhciCrashed "src/Foo.hs:42:5: error: Variable not in scope: foo"-- it "prefers exit exception over compile-error heuristic when both appear" do+ @?= GhciCrashed "src/Foo.hs:42:5: error: Variable not in scope: foo"+ , testCase "prefers exit exception over compile-error heuristic when both appear" do -- A real failing run could plausibly mention 'error:' in its -- captured output (e.g. logged messages); the ExitFailure line -- still wins. detectOutcome "log: error: something happened\n*** Exception: ExitFailure 1\n"- `shouldBe` GhciFailed+ @?= GhciFailed+ ]+ ] -------------------------------------------------------------------------------- -- Scripted interpreter tests -------------------------------------------------------------------------------- -testScripted :: Spec-testScripted = do- it "returns scripted TestRun" do+testScripted :: [TestTree]+testScripted =+ [ testCase "returns scripted TestRun" do result <- runScripted [Right passingRun]- $ runTestSuite noProgress Nothing Cabal testTimeout- $ mkTestTarget "test:foo"- result `shouldBe` passingRun-- it "ignores the target name argument" do+ $ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:foo"+ result @?= passingRun+ , testCase "ignores the target name argument" do result <- runScripted [Right failingRun]- $ runTestSuite noProgress Nothing Cabal testTimeout- $ mkTestTarget "test:anything"- result `shouldBe` failingRun-- it "throws when scripted result is Left" do+ $ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:anything"+ result @?= failingRun+ , testCase "throws when scripted result is Left" do result <- runScripted [Left (toException boom)] $ try @ErrorCall- $ runTestSuite noProgress Nothing Cabal testTimeout- $ mkTestTarget "test:foo"- result `shouldBe` Left boom-- describe "sequencing" do- it "consumes results in order across multiple calls" do+ $ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:foo"+ result @?= Left boom+ , testGroup+ "sequencing"+ [ testCase "consumes results in order across multiple calls" do (a, b) <- runScripted [Right passingRun, Right failingRun] do- a <- runTestSuite noProgress Nothing Cabal testTimeout $ mkTestTarget "test:foo"- b <- runTestSuite noProgress Nothing Cabal testTimeout $ mkTestTarget "test:bar"+ a <-+ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:foo"+ b <-+ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:bar" pure (a, b)- a `shouldBe` passingRun- b `shouldBe` failingRun-- it "recover scenario: error then success" do+ a @?= passingRun+ b @?= failingRun+ , testCase "recover scenario: error then success" do result <- runScripted [Left (toException boom), Right passingRun] do r1 <- try @ErrorCall- $ runTestSuite noProgress Nothing Cabal testTimeout- $ mkTestTarget "test:foo"+ $ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:foo" r2 <-- runTestSuite noProgress Nothing Cabal testTimeout- $ mkTestTarget "test:bar"+ runTestSuite noProgress testTimeout+ $ mkResolved+ $ RenderedCommand+ $ "cabal repl test:bar" pure (r1, r2)- fst result `shouldBe` Left boom- snd result `shouldBe` passingRun+ fst result @?= Left boom+ snd result @?= passingRun+ ]+ ] --------------------------------------------------------------------------------@@ -180,8 +200,8 @@ runScripted results = runEff . runConcurrent . TestRunner.runScripted results -mkTestTarget :: Text -> TestTarget-mkTestTarget = TestTarget . Bare+mkResolved :: RenderedCommand 'Stage.Test -> RenderedTestCommand+mkResolved cmd = RenderedTestCommand cmd def testTimeout :: TestTimeout
test/Unit/Tricorder/Daemon/WatchSpec.hs view
@@ -1,9 +1,10 @@-module Unit.Tricorder.Daemon.WatchSpec (spec_Watch) where+module Unit.Tricorder.Daemon.WatchSpec (test_Watch) where import Atelier.Effects.FileWatcher (FileEvent (..), matchesAny) import Effectful (runEff) import Effectful.Writer.Static.Shared (execWriter, runWriter)-import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Text.Regex.TDFA.ReadRegex (parseRegex) import Atelier.Effects.Publishing.Pub qualified as Pub@@ -20,21 +21,30 @@ import Tricorder.Daemon.Watch qualified as Watch -spec_Watch :: Spec-spec_Watch = do- describe "publishChange" testPublishChange- describe "specs" testSpecs+test_Watch :: TestTree+test_Watch =+ testGroup+ "Watch"+ [ testGroup "publishChange" testPublishChange+ , testGroup "specs" testSpecs+ ] -testPublishChange :: Spec-testPublishChange = do- describe "with non-cabal file change" $ it "should publish SourceChangeDetected" do- (_, sourceChanges) <- runTest "foo"- sourceChanges `shouldMatchList` [SourceChangeDetected "foo" Modified]-- describe "with cabal file change" $ it "should publish CabalChangeDetected" do- (cabalChanges, _) <- runTest "foo.cabal"- cabalChanges `shouldMatchList` [CabalChangeDetected "foo.cabal" Modified]+testPublishChange :: [TestTree]+testPublishChange =+ [ testGroup+ "with non-cabal file change"+ [ testCase "should publish SourceChangeDetected" do+ (_, sourceChanges) <- runTest "foo"+ sourceChanges @?= [SourceChangeDetected "foo" Modified]+ ]+ , testGroup+ "with cabal file change"+ [ testCase "should publish CabalChangeDetected" do+ (cabalChanges, _) <- runTest "foo.cabal"+ cabalChanges @?= [CabalChangeDetected "foo.cabal" Modified]+ ]+ ] where runTest = runEff@@ -46,105 +56,100 @@ . (`WatchedFile` Modified) -testSpecs :: Spec-testSpecs = do- describe "source watches" do- it "matches .hs files in configured watch dirs" do+testSpecs :: [TestTree]+testSpecs =+ [ testGroup+ "source watches"+ [ testCase "matches .hs files in configured watch dirs" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs ["/proj/src"])- matchesAny watches "/proj/src/Foo.hs" `shouldBe` True-- it "does not match non-.hs files" do+ matchesAny watches "/proj/src/Foo.hs" @?= True+ , testCase "does not match non-.hs files" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs ["/proj/src"])- matchesAny watches "/proj/src/Foo.txt" `shouldBe` False-- it "excludes paths containing dist-newstyle" do+ matchesAny watches "/proj/src/Foo.txt" @?= False+ , testCase "excludes paths containing dist-newstyle" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs ["/proj/src"])- matchesAny watches "/proj/src/dist-newstyle/Foo.hs" `shouldBe` False-- it "excludes paths matching an exclusion pattern" do+ matchesAny watches "/proj/src/dist-newstyle/Foo.hs" @?= False+ , testCase "excludes paths matching an exclusion pattern" do let pat = parsePattern "vendor" watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns [pat]) (WatchDirs ["/proj/src"])- matchesAny watches "/proj/src/vendor/Foo.hs" `shouldBe` False- matchesAny watches "/proj/src/Foo.hs" `shouldBe` True-- it "matches .hs files across multiple watch dirs" do+ matchesAny watches "/proj/src/vendor/Foo.hs" @?= False+ matchesAny watches "/proj/src/Foo.hs" @?= True+ , testCase "matches .hs files across multiple watch dirs" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs ["/proj/src", "/proj/test"])- matchesAny watches "/proj/src/Foo.hs" `shouldBe` True- matchesAny watches "/proj/test/FooSpec.hs" `shouldBe` True-- -- Second line of defense: 'cabalWatches' registers the whole project- -- root, and 'deduplicateDirs' collapses the narrow source dirs into it,- -- so the OS watches the entire repo recursively. 'matchesAny' is what- -- re-scopes events back to the configured dirs — a .hs file in a sibling- -- package must not match.- it "does not match a .hs file in a sibling package outside the watch dirs" do+ matchesAny watches "/proj/src/Foo.hs" @?= True+ matchesAny watches "/proj/test/FooSpec.hs" @?= True+ , -- Second line of defense: 'cabalWatches' registers the whole project+ -- root, and 'deduplicateDirs' collapses the narrow source dirs into it,+ -- so the OS watches the entire repo recursively. 'matchesAny' is what+ -- re-scopes events back to the configured dirs — a .hs file in a sibling+ -- package must not match.+ testCase "does not match a .hs file in a sibling package outside the watch dirs" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs ["/proj/pkg-a/src"])- matchesAny watches "/proj/pkg-a/src/Foo.hs" `shouldBe` True- matchesAny watches "/proj/pkg-b/src/Foo.hs" `shouldBe` False-- describe "cabal watches" do- it "matches .cabal files under project root" do+ matchesAny watches "/proj/pkg-a/src/Foo.hs" @?= True+ matchesAny watches "/proj/pkg-b/src/Foo.hs" @?= False+ ]+ , testGroup+ "cabal watches"+ [ testCase "matches .cabal files under project root" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs [])- matchesAny watches "/proj/foo.cabal" `shouldBe` True-- it "matches cabal.project under project root" do+ matchesAny watches "/proj/foo.cabal" @?= True+ , testCase "matches cabal.project under project root" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs [])- matchesAny watches "/proj/cabal.project" `shouldBe` True-- it "matches package.yaml under project root" do+ matchesAny watches "/proj/cabal.project" @?= True+ , testCase "matches package.yaml under project root" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs [])- matchesAny watches "/proj/package.yaml" `shouldBe` True-- it "does not match non-cabal files" do+ matchesAny watches "/proj/package.yaml" @?= True+ , testCase "does not match non-cabal files" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs [])- matchesAny watches "/proj/README.md" `shouldBe` False-- it "excludes cabal files under dist-newstyle" do+ matchesAny watches "/proj/README.md" @?= False+ , testCase "excludes cabal files under dist-newstyle" do let watches = Watch.specs (ProjectRoot "/proj") (WatchExclusionPatterns []) (WatchDirs [])- matchesAny watches "/proj/dist-newstyle/foo.cabal" `shouldBe` False+ matchesAny watches "/proj/dist-newstyle/foo.cabal" @?= False+ ]+ ] where parsePattern p = fromRight (error . toText $ "bad test pattern: " <> p) (parseRegex p)
test/Unit/Tricorder/Session/CabalFileSpec.hs view
@@ -1,23 +1,37 @@-module Unit.Tricorder.Session.CabalFileSpec (spec_CabalFile) where+module Unit.Tricorder.Session.CabalFileSpec (test_CabalFile) where import Atelier.Effects.Env (runEnvConst) import Atelier.Effects.FileSystem (runFileSystemState)+import Atelier.Effects.Input (runInputConst)+import Atelier.Effects.Log (runLogNoOp) import Effectful (runPureEff) import Effectful.Reader.Static (runReader) import Effectful.State.Static.Shared (evalState)-import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Atelier.Effects.FileSystem.Glob qualified as Glob import Data.Map.Strict qualified as Map import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.CabalFile (discoverCabalFiles)+import Tricorder.Session.CabalFile+ ( CabalFile (..)+ , discoverCabalPackages+ , discoverStackPackages+ , readProjectFile+ )+import Tricorder.Session.StackProject (StackProject (..)) import Unit.Tricorder.Session.Helpers (cabalFixture, multiPackageCabalFs, multiPackageFs) -spec_CabalFile :: Spec-spec_CabalFile = do- describe "discoverCabalFiles" testDiscoverCabalFiles+test_CabalFile :: TestTree+test_CabalFile =+ testGroup+ "CabalFile"+ [ testGroup "discoverCabalPackages" testDiscoverCabalPackages+ , testGroup "discoverStackPackages" testDiscoverStackPackages+ , testGroup "readProjectFile" testReadProjectFile+ ] -- | Pins the discovery contract: a @cabal.project@ (or @.local@/@.freeze@@@ -25,109 +39,118 @@ -- otherwise the @.cabal@ files in the project root are used. Falls back -- further to @$HOME/.cabal/config@'s @packages:@ stanza if none of the -- project-root files exist.-testDiscoverCabalFiles :: Spec-testDiscoverCabalFiles = do- describe "when there is no cabal.project" do- it "finds the .cabal files in the project root" do+testDiscoverCabalPackages :: [TestTree]+testDiscoverCabalPackages =+ [ testGroup+ "when there is no cabal.project"+ [ testCase "finds the .cabal files in the project root" do let actual = runDiscovery (Map.singleton "/myapp.cabal" cabalFixture) []- $ discoverCabalFiles- actual `shouldBe` ["/myapp.cabal"]-- it "returns no files when the root has no cabal file" do- let actual = runDiscovery mempty [] discoverCabalFiles- actual `shouldBe` []-- describe "when there is a multi-package cabal.project" do- it "resolves each listed package to its .cabal (regression: was root-only)" do- let actual = runDiscovery multiPackageFs [] discoverCabalFiles- actual `shouldBe` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]-- describe "priority among cabal.project.local, cabal.project.freeze, and cabal.project" do- it "prefers cabal.project.local over cabal.project" do+ $ discoverCabalPackages+ actual @?= Right ["/myapp.cabal"]+ , testCase "returns no files when the root has no cabal file" do+ let actual = runDiscovery mempty [] discoverCabalPackages+ actual @?= Right []+ ]+ , testGroup+ "when there is a multi-package cabal.project"+ [ testCase "resolves each listed package to its .cabal (regression: was root-only)" do+ let actual = runDiscovery multiPackageFs [] discoverCabalPackages+ actual @?= Right ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]+ ]+ , testGroup+ "priority among cabal.project.local, cabal.project.freeze, and cabal.project"+ [ testCase "prefers cabal.project.local over cabal.project" do let fs = Map.fromList [ ("/cabal.project.local", "packages: pkg-a\n") , ("/cabal.project", "packages: pkg-b\n") ] `Map.union` multiPackageCabalFs- actual = runDiscovery fs [] discoverCabalFiles- actual `shouldBe` ["/pkg-a/pkg-a.cabal"]-- it "prefers cabal.project.freeze over cabal.project" do+ actual = runDiscovery fs [] discoverCabalPackages+ actual @?= Right ["/pkg-a/pkg-a.cabal"]+ , testCase "prefers cabal.project.freeze over cabal.project" do let fs = Map.fromList [ ("/cabal.project.freeze", "packages: pkg-a\n") , ("/cabal.project", "packages: pkg-b\n") ] `Map.union` multiPackageCabalFs- actual = runDiscovery fs [] discoverCabalFiles- actual `shouldBe` ["/pkg-a/pkg-a.cabal"]-- describe "when a higher-priority file lists no packages" do- it "falls through to the next file in priority order" do+ actual = runDiscovery fs [] discoverCabalPackages+ actual @?= Right ["/pkg-a/pkg-a.cabal"]+ , testGroup+ "when a higher-priority file lists no packages"+ [ testCase "falls through to the next file in priority order" do let fs = Map.fromList [ ("/cabal.project.local", "tests: True\n") , ("/cabal.project", "packages: pkg-b\n") ] `Map.union` multiPackageCabalFs- actual = runDiscovery fs [] discoverCabalFiles- actual `shouldBe` ["/pkg-b/pkg-b.cabal"]-- describe "packages: entry resolution" do- it "uses a direct .cabal path entry verbatim, without scanning a directory" do+ actual = runDiscovery fs [] discoverCabalPackages+ actual @?= Right ["/pkg-b/pkg-b.cabal"]+ ]+ ]+ , testGroup+ "packages: entry resolution"+ [ testCase "uses a direct .cabal path entry verbatim, without scanning a directory" do let fs = Map.singleton "/cabal.project" "packages: sub/foo.cabal\n"- actual = runDiscovery fs [] discoverCabalFiles- actual `shouldBe` ["/sub/foo.cabal"]-- it "expands a glob entry matching .cabal files directly" do+ actual = runDiscovery fs [] discoverCabalPackages+ actual @?= Right ["/sub/foo.cabal"]+ , testCase "expands a glob entry matching .cabal files directly" do let fs = Map.singleton "/cabal.project" "packages: */*.cabal\n" script = [Glob.NextGlobDir1 ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]]- actual = runDiscoveryGlob fs [] script discoverCabalFiles- actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]-- it "expands a glob entry matching package directories" do+ actual = runDiscoveryGlob fs [] script discoverCabalPackages+ fromRight [] actual @?= ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]+ , testCase "expands a glob entry matching package directories" do let fs = Map.singleton "/cabal.project" "packages: */\n" `Map.union` multiPackageCabalFs script = [Glob.NextGlobDir1 ["/pkg-a", "/pkg-b"]]- actual = runDiscoveryGlob fs [] script discoverCabalFiles- actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]-- it "returns no files when a glob entry matches nothing" do+ actual = runDiscoveryGlob fs [] script discoverCabalPackages+ fromRight [] actual @?= ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]+ , testCase "returns no files when a glob entry matches nothing" do let fs = Map.singleton "/cabal.project" "packages: */*.cabal\n" script = [Glob.NextGlobDir1 []]- actual = runDiscoveryGlob fs [] script discoverCabalFiles- actual `shouldBe` []-- describe "$HOME/.cabal/config fallback" do- describe "when no cabal.project files exist" do- it "uses $HOME/.cabal/config as a last-resort packages source" do+ actual = runDiscoveryGlob fs [] script discoverCabalPackages+ actual @?= Right []+ ]+ , testGroup+ "$HOME/.cabal/config fallback"+ [ testGroup+ "when no cabal.project files exist"+ [ testCase "uses $HOME/.cabal/config as a last-resort packages source" do let fs = Map.singleton "/home/user/.cabal/config" "packages: pkg-a\n" `Map.union` multiPackageCabalFs- actual = runDiscovery fs [("HOME", "/home/user")] discoverCabalFiles- actual `shouldBe` ["/pkg-a/pkg-a.cabal"]-- describe "when $HOME/.cabal/config exists but lists no packages" do- it "falls back to scanning the project root" do+ actual = runDiscovery fs [("HOME", "/home/user")] discoverCabalPackages+ actual @?= Right ["/pkg-a/pkg-a.cabal"]+ ]+ , testGroup+ "when $HOME/.cabal/config exists but lists no packages"+ [ testCase "falls back to scanning the project root" do let fs = Map.fromList [ ("/home/user/.cabal/config", "") , ("/myapp.cabal", cabalFixture) ]- actual = runDiscovery fs [("HOME", "/home/user")] discoverCabalFiles- actual `shouldBe` ["/myapp.cabal"]-- describe "packages: single-line list" do- describe "when package list is comma-separated" do- it "parses package names correctly" do+ actual = runDiscovery fs [("HOME", "/home/user")] discoverCabalPackages+ actual @?= Right ["/myapp.cabal"]+ ]+ ]+ , testGroup+ "packages: single-line list"+ [ testGroup+ "when package list is comma-separated"+ [ testCase "parses package names correctly" do let fs = Map.singleton "/cabal.project" "packages: pkg-a, pkg-b\n" `Map.union` multiPackageCabalFs- actual = runDiscovery fs [] discoverCabalFiles- actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]+ actual = runDiscovery fs [] discoverCabalPackages+ fromRight [] actual @?= ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]+ ]+ ]+ ] where pr = ProjectRoot "/" runDiscovery fs env = runDiscoveryGlob fs env []@@ -138,3 +161,49 @@ . runFileSystemState . Glob.runScripted script . runReader pr+++-- | Pins the discovery contract: with a @stack.yaml@, package paths come+-- straight from its @packages:@ list, resolved against the project root.+testDiscoverStackPackages :: [TestTree]+testDiscoverStackPackages =+ [ testCase "resolves each package path against the project root" do+ let actual = runStack (StackProject ["pkg-a", "pkg-b"]) discoverStackPackages+ actual @?= Right ["/pkg-a", "/pkg-b"]+ , testCase "normalises resolved paths" do+ let actual = runStack (StackProject ["./pkg-a"]) discoverStackPackages+ actual @?= Right ["/pkg-a"]+ , testCase "returns no packages when the list is empty" do+ let actual = runStack (StackProject []) discoverStackPackages+ actual @?= Right []+ ]+ where+ pr = ProjectRoot "/"+ runStack result =+ runPureEff+ . runReader pr+ . runLogNoOp+ . runInputConst result+++-- | Pins how a package path resolves to a @.cabal@ file: a directory path+-- (as listed in @stack.yaml@) reads the @.cabal@ file inside it.+testReadProjectFile :: [TestTree]+testReadProjectFile =+ [ testGroup+ "when the path is a directory"+ [ testCase "reads the .cabal file inside it" do+ let actual = runRead multiPackageCabalFs $ readProjectFile "/pkg-a"+ actual @?= Right "/pkg-a/pkg-a.cabal"+ , testCase "fails with the directory path when it contains no .cabal file" do+ let fs = Map.singleton "/pkg-a/package.yaml" "name: pkg-a\n"+ actual = runRead fs $ readProjectFile "/pkg-a"+ actual @?= Left "/pkg-a"+ ]+ ]+ where+ runRead fs =+ fmap (.projectFilePath)+ . runPureEff+ . evalState fs+ . runFileSystemState
− test/Unit/Tricorder/Session/CommandSpec.hs
@@ -1,123 +0,0 @@-module Unit.Tricorder.Session.CommandSpec (spec_Command) where--import Atelier.Effects.FileSystem (runFileSystemState)-import Data.Default (def)-import Effectful (runPureEff)-import Effectful.State.Static.Shared (evalState)-import Test.Hspec (Spec, describe, it, shouldBe)--import Data.Map.Strict qualified as Map--import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command (resolveCommand)-import Tricorder.Session.Config (Config (..))-import Tricorder.Session.Target (parseTarget)-import Tricorder.Session.TestTarget (parseTestTargets)--import Tricorder.Session.Command qualified as Command-import Tricorder.Session.Config qualified as Config---spec_Command :: Spec-spec_Command = do- describe "resolveCommand" testResolveCommand---testResolveCommand :: Spec-testResolveCommand = do- describe "when config has a command" do- it "should use specified command" do- let actual =- Command.render- . runPureEff- . evalState mempty- . runFileSystemState- $ resolveCommand pr def {command = Just "foo"} [] testTargets- actual `shouldBe` "foo"-- describe "when config has explicit targets" do- it "should spell them out verbatim, ignoring discovered test targets" do- let actual =- Command.render- . runPureEff- . evalState (Map.singleton "/cabal.project" "")- . runFileSystemState- $ resolveCommand pr cfg (parseTarget <$> ["lib:foo"]) testTargets- actual `shouldBe` "cabal repl --enable-multi-repl --builddir /replbuild lib:foo"-- describe "when config does not have a command or targets" do- describe "and there is a cabal.project file" do- it "should use cabal 'all' plus the discovered test targets" do- let actual =- Command.render- . runPureEff- . evalState (Map.singleton "/cabal.project" "")- . runFileSystemState- $ resolveCommand pr cfg [] testTargets- actual- `shouldBe` "cabal repl --enable-multi-repl --builddir /replbuild all test:foo"-- describe "and there is at least one *.cabal file" do- it "should use cabal 'all' plus the discovered test targets" do- let actual =- Command.render- . runPureEff- . evalState (Map.singleton "/foo.cabal" "")- . runFileSystemState- $ resolveCommand pr cfg [] testTargets- actual- `shouldBe` "cabal repl --enable-multi-repl --builddir /replbuild all test:foo"-- describe "and there is a stack.yaml file" do- it "should use stack ghci with 'all' plus test targets" do- let actual =- Command.render- . runPureEff- . evalState (Map.singleton "/stack.yaml" "")- . runFileSystemState- $ resolveCommand pr cfg [] testTargets- actual `shouldBe` "stack ghci all foo"-- describe "and there is both a stack.yaml and a cabal.project file" do- it "should prefer stack ghci over cabal" do- let actual =- Command.render- . runPureEff- . evalState (Map.fromList [("/stack.yaml", ""), ("/cabal.project", "")])- . runFileSystemState- $ resolveCommand pr cfg [] testTargets- actual `shouldBe` "stack ghci all foo"-- describe "and there is both a stack.yaml and a *.cabal file" do- it "should prefer stack ghci over cabal" do- let actual =- Command.render- . runPureEff- . evalState (Map.fromList [("/stack.yaml", ""), ("/foo.cabal", "")])- . runFileSystemState- $ resolveCommand pr cfg [] testTargets- actual `shouldBe` "stack ghci all foo"-- describe "but there are no project files" do- it "should use default cabal repl with 'all' plus test targets" do- let actual =- Command.render- . runPureEff- . evalState mempty- . runFileSystemState- $ resolveCommand pr cfg [] testTargets- actual `shouldBe` "cabal repl --builddir /replbuild all test:foo"-- describe "and no test targets are discovered" do- it "should fall back to plain 'all'" do- let actual =- Command.render- . runPureEff- . evalState (Map.singleton "/cabal.project" "")- . runFileSystemState- $ resolveCommand pr cfg [] (parseTestTargets [])- actual `shouldBe` "cabal repl --enable-multi-repl --builddir /replbuild all"- where- pr = ProjectRoot "/"- cfg = def {Config.replBuildDir = "/replbuild"}- testTargets = parseTestTargets ["test:foo"]
+ test/Unit/Tricorder/Session/CommandTemplateSpec.hs view
@@ -0,0 +1,58 @@+module Unit.Tricorder.Session.CommandTemplateSpec (test_Command) where++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Tricorder.Session.CommandTemplate+ ( hasPlaceholder+ , renderTargetsFor+ , targetPlaceholder+ , targetsPlaceholder+ )+import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Target (ComponentKind (..), Target (..))+++test_Command :: TestTree+test_Command =+ testGroup+ "Command"+ [ testGroup "renderTargetsFor" testRenderTargetsFor+ , testGroup "hasPlaceholder" testHasPlaceholder+ ]+++--------------------------------------------------------------------------------+-- renderTargetsFor+--------------------------------------------------------------------------------++testRenderTargetsFor :: [TestTree]+testRenderTargetsFor =+ [ testCase "renders bare component names for plain Stack, deduplicated" do+ renderTargetsFor Stack [Qualified Test "foo", PackageQualified "pkg" Test "foo"]+ @?= ["foo"]+ , testCase "renders fully qualified targets for StackMulti" do+ renderTargetsFor StackMulti [PackageQualified "pkg" Test "foo"]+ @?= ["pkg:test:foo"]+ , testCase "renders fully qualified targets for Cabal" do+ renderTargetsFor Cabal [Qualified Test "foo"] @?= ["test:foo"]+ ]+++--------------------------------------------------------------------------------+-- hasPlaceholder+--------------------------------------------------------------------------------++testHasPlaceholder :: [TestTree]+testHasPlaceholder =+ [ testCase "is True when {targets} is present and checking for targetsPlaceholder" do+ hasPlaceholder targetsPlaceholder "cabal repl {targets}" @?= True+ , testCase "is True when only the escaped \\{targets} is present" do+ hasPlaceholder targetsPlaceholder "echo \\{targets}" @?= True+ , testCase "is False when neither form is present" do+ hasPlaceholder targetsPlaceholder "cabal repl test:foo" @?= False+ , testCase "is True when {target} is present and checking for targetPlaceholder" do+ hasPlaceholder targetPlaceholder "cabal repl {target}" @?= True+ , testCase "is False for {targets} (plural) when checking for targetPlaceholder" do+ hasPlaceholder targetPlaceholder "cabal repl {targets}" @?= False+ ]
+ test/Unit/Tricorder/Session/ReplSpec.hs view
@@ -0,0 +1,52 @@+module Unit.Tricorder.Session.ReplSpec (test_Repl) where++import Atelier.Effects.FileSystem (FileSystem, runFileSystemState)+import Effectful (runPureEff)+import Effectful.State.Static.Shared (State, evalState)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Data.Map.Strict qualified as Map++import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.Repl (Repl (..), resolveRepl)+++test_Repl :: TestTree+test_Repl =+ testGroup+ "Repl"+ [ testGroup "resolveRepl" testResolveRepl+ ]+++testResolveRepl :: [TestTree]+testResolveRepl =+ [ testCase "resolves Stack for a single-package stack.yaml" do+ withFiles [("/stack.yaml", "")] (resolveRepl pr) @?= Stack+ , testCase "resolves StackMulti for a multi-package stack.yaml" do+ withFiles+ [("/stack.yaml", "packages:\n - foo\n - bar\n")]+ (resolveRepl pr)+ @?= StackMulti+ , testCase "resolves Cabal when there is a cabal.project file" do+ withFiles [("/cabal.project", "")] (resolveRepl pr) @?= Cabal+ , testCase "resolves Cabal when there is at least one *.cabal file" do+ withFiles [("/foo.cabal", "")] (resolveRepl pr) @?= Cabal+ , testCase "prefers Stack over Cabal when both are present" do+ withFiles [("/stack.yaml", ""), ("/cabal.project", "")] (resolveRepl pr) @?= Stack+ , testCase "falls back to Cabal when there are no project files at all" do+ withFiles [] (resolveRepl pr) @?= Cabal+ ]+++-- | Run a 'FileSystem'-using computation against a faked in-memory+-- filesystem seeded with the given files (content is irrelevant except for+-- @stack.yaml@, which is parsed for its @packages@ key).+withFiles :: [(FilePath, ByteString)] -> Eff '[FileSystem, State (Map FilePath ByteString)] a -> a+withFiles files action =+ runPureEff $ evalState (Map.fromList files) $ runFileSystemState action+++pr :: ProjectRoot+pr = ProjectRoot "/"
+ test/Unit/Tricorder/Session/Stage/Build/CommandSpec.hs view
@@ -0,0 +1,193 @@+module Unit.Tricorder.Session.Stage.Build.CommandSpec (test_Command) where++import Atelier.Effects.FileSystem (FileSystem, runFileSystemState)+import Data.Default (def)+import Effectful (runPureEff)+import Effectful.State.Static.Shared (State, evalState)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Data.Map.Strict qualified as Map++import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..), targetsPlaceholder)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl (..), resolveRepl)+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Stage.Build.Command (render, resolve)+import Tricorder.Session.Target (Target)++import Tricorder.Session.Config qualified as Config+import Tricorder.Session.Target qualified as Target+++test_Command :: TestTree+test_Command =+ testGroup+ "Tricorder.Session.Stage.Build.Command"+ [ testGroup "resolveBuildCommand" testResolveBuildCommand+ , testGroup "renderBuild" testRenderBuild+ ]+++testRenderBuild :: [TestTree]+testRenderBuild =+ [ testCase "substitutes {targets} with the rendered target list" do+ build (CommandTemplate Cabal "cabal repl {targets}" [] targetsPlaceholder) [Target.parse "lib:foo"]+ @?= "cabal repl lib:foo"+ , testCase "substitutes every occurrence of {targets}" do+ build+ (CommandTemplate Cabal "echo {targets} && cabal repl {targets}" [] targetsPlaceholder)+ [Target.parse "lib:foo"]+ @?= "echo lib:foo && cabal repl lib:foo"+ , testCase "leaves a template with no placeholder untouched, but still appends arguments" do+ build+ (CommandTemplate Cabal "my-wrapper --repl" ["--flag"] targetsPlaceholder)+ [Target.parse "lib:foo"]+ @?= "my-wrapper --repl --flag"+ , testCase "renders \\{targets} as a literal {targets}, without substitution" do+ build (CommandTemplate Cabal "echo \\{targets}" [] targetsPlaceholder) [Target.parse "lib:foo"]+ @?= "echo {targets}"+ , testCase "substitutes an unescaped {targets} while leaving an escaped one literal" do+ build+ (CommandTemplate Cabal "echo \\{targets} && cabal repl {targets}" [] targetsPlaceholder)+ [Target.parse "lib:foo"]+ @?= "echo {targets} && cabal repl lib:foo"+ , testCase "substitutes {targets} with nothing when the target list is empty" do+ build (CommandTemplate Cabal "cabal repl {targets}" [] targetsPlaceholder) []+ @?= "cabal repl"+ , testCase "appends arguments after the rendered template" do+ build+ (CommandTemplate Cabal "cabal repl {targets}" ["--flag", "value"] targetsPlaceholder)+ [Target.parse "lib:foo"]+ @?= "cabal repl lib:foo --flag value"+ , testCase "does not substitute {target} (singular) when the command uses targetsPlaceholder" do+ build (CommandTemplate Cabal "cabal repl {target}" [] targetsPlaceholder) [Target.parse "lib:foo"]+ @?= "cabal repl {target}"+ ]+ where+ build template targets = (render template targets).getRenderedCommand+++testResolveBuildCommand :: [TestTree]+testResolveBuildCommand =+ [ testGroup+ "deprecated top-level command"+ [ testCase "is used as the build template when build.command_template is unset" do+ renderBuildFor [] def {command = Just "foo"} [] @?= "foo"+ ]+ , testGroup+ "build.command_template"+ [ testCase "overrides the deprecated top-level command" do+ let cfg =+ cfg0+ { command = Just "should be ignored"+ , build = cfg0.build {commandTemplate = Just "cabal repl {targets}"}+ }+ renderBuildFor [("/cabal.project", "")] cfg (Target.parse <$> ["lib:foo"])+ @?= "cabal repl lib:foo"+ ]+ , testGroup+ "explicit targets"+ [ testCase "spell them out verbatim" do+ renderBuildFor [("/cabal.project", "")] cfg0 (Target.parse <$> ["lib:foo"])+ @?= "cabal repl --enable-multi-repl --builddir /replbuild lib:foo"+ ]+ , testGroup+ "no command or targets configured"+ [ testGroup+ "and there is a cabal.project file"+ [ testCase "uses cabal 'all'" do+ renderBuildFor [("/cabal.project", "")] cfg0 []+ @?= "cabal repl --enable-multi-repl --builddir /replbuild all"+ ]+ , testGroup+ "and there is at least one *.cabal file"+ [ testCase "uses cabal 'all'" do+ renderBuildFor [("/foo.cabal", "")] cfg0 []+ @?= "cabal repl --enable-multi-repl --builddir /replbuild all"+ ]+ , testGroup+ "and there is a stack.yaml file"+ [ testCase "uses stack ghci with 'all'" do+ renderBuildFor [("/stack.yaml", "")] cfg0 []+ @?= "stack ghci all"+ ]+ , testGroup+ "and there is both a stack.yaml and a cabal.project file"+ [ testCase "prefers stack ghci over cabal" do+ renderBuildFor [("/stack.yaml", ""), ("/cabal.project", "")] cfg0 []+ @?= "stack ghci all"+ ]+ , testGroup+ "but there are no project files"+ [ testCase "uses default cabal repl with 'all'" do+ renderBuildFor [] cfg0 []+ @?= "cabal repl --builddir /replbuild all"+ ]+ ]+ , testGroup+ "build.extra_auto_arguments"+ [ testCase "is appended after the rendered automatically resolved template" do+ let cfg = cfg0 {build = cfg0.build {extraAutoArguments = ["--extra-flag"]}}+ renderBuildFor [("/cabal.project", "")] cfg (Target.parse <$> ["lib:foo"])+ @?= "cabal repl --enable-multi-repl --builddir /replbuild lib:foo --extra-flag"+ , testCase "is ignored when build.command_template is set" do+ let cfg =+ cfg0+ { build =+ cfg0.build+ { commandTemplate = Just "cabal repl {targets}"+ , extraAutoArguments = ["--extra-flag"]+ }+ }+ renderBuildFor [("/cabal.project", "")] cfg (Target.parse <$> ["lib:foo"])+ @?= "cabal repl lib:foo"+ , testCase "is ignored when the deprecated top-level command is set" do+ let cfg =+ cfg0+ { command = Just "cabal repl {targets}"+ , build = cfg0.build {extraAutoArguments = ["--extra-flag"]}+ }+ renderBuildFor [("/cabal.project", "")] cfg (Target.parse <$> ["lib:foo"])+ @?= "cabal repl lib:foo"+ ]+ ]+++-- | Resolve and fully render the build command against a faked filesystem —+-- the composition 'Tricorder.Session.loadSession' and 'Tricorder.Daemon.Core'+-- perform between them (resolve a 'CommandTemplate' plus its target list,+-- then 'renderBuild' the two together).+renderBuildFor :: [(FilePath, ByteString)] -> Config -> [Target] -> Text+renderBuildFor files cfg targets =+ (uncurry render $ withFiles files $ resolveBuild cfg targets).getRenderedCommand+++-- | Run a 'FileSystem'-using computation against a faked in-memory+-- filesystem seeded with the given files (content is irrelevant except for+-- @stack.yaml@, which is parsed for its @packages@ key).+withFiles :: [(FilePath, ByteString)] -> Eff '[FileSystem, State (Map FilePath ByteString)] a -> a+withFiles files action =+ runPureEff $ evalState (Map.fromList files) $ runFileSystemState action+++cfg0 :: Config+cfg0 = def {Config.replBuildDir = "/replbuild"}+++-- | Resolve 'Repl' from the faked filesystem, then resolve the build+-- command from it — mirrors how 'Tricorder.Session.loadSession' chains the+-- two steps.+resolveBuild+ :: (FileSystem :> es)+ => Config -> [Target] -> Eff es (CommandTemplate 'Build, [Target])+resolveBuild cfg targets = do+ repl <- resolveRepl pr+ resolve pr cfg repl targets+++pr :: ProjectRoot+pr = ProjectRoot "/"
+ test/Unit/Tricorder/Session/Stage/Eval/CommandSpec.hs view
@@ -0,0 +1,62 @@+module Unit.Tricorder.Session.Stage.Eval.CommandSpec (test_Command) where++import Data.Default (def)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..), targetPlaceholder)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Stage (Stage (..))+import Tricorder.Session.Stage.Eval.Command (render, resolve)+import Tricorder.Session.Target (Target (..))+++test_Command :: TestTree+test_Command =+ testGroup+ "Tricorder.Session.Stage.Eval.Command"+ [ testGroup "resolveEvalCommand" testResolveEvalCommand+ , testGroup "renderEval" testRenderEval+ ]+++testRenderEval :: [TestTree]+testRenderEval =+ [ testCase "substitutes {target} (singular) with the module being evaluated" do+ eval (CommandTemplate Cabal "cabal repl {target}" [] targetPlaceholder) [Bare "Tricorder.Foo"]+ @?= "cabal repl Tricorder.Foo"+ , testCase "does not substitute {targets} (plural) when the command uses targetPlaceholder" do+ eval (CommandTemplate Cabal "cabal repl {targets}" [] targetPlaceholder) [Bare "Tricorder.Foo"]+ @?= "cabal repl {targets}"+ ]+ where+ eval template targets = (render template targets).getRenderedCommand+++testResolveEvalCommand :: [TestTree]+testResolveEvalCommand =+ [ testCase "uses the built-in default template for Cabal when eval.command_template is unset" do+ (resolve Cabal def).template @?= "cabal repl {target}"+ , testCase "uses eval.command_template when set" do+ let cfg =+ def+ { eval = (def :: CommandConfig 'Eval) {commandTemplate = Just "cabal repl --builddir /tmp {target}"}+ }+ (resolve Cabal cfg).template @?= "cabal repl --builddir /tmp {target}"+ , testCase "carries eval.extra_auto_arguments when eval.command_template is unset" do+ let cfg = def {eval = (def :: CommandConfig 'Eval) {extraAutoArguments = ["--flag"]}}+ (resolve Cabal cfg).arguments @?= ["--flag"]+ , testCase "ignores eval.extra_auto_arguments when eval.command_template is set" do+ let cfg =+ def+ { eval =+ (def :: CommandConfig 'Eval)+ { commandTemplate = Just "cabal repl {target}"+ , extraAutoArguments = ["--flag"]+ }+ }+ (resolve Cabal cfg).arguments @?= []+ ]
+ test/Unit/Tricorder/Session/Stage/Test/CommandSpec.hs view
@@ -0,0 +1,106 @@+module Unit.Tricorder.Session.Stage.Test.CommandSpec (test_Command) where++import Data.Default (def)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Tricorder.Build.ByteSize (ByteSize (..), Unit (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.CommandConfig (CommandConfig (..))+import Tricorder.Session.CommandTemplate (CommandTemplate (..), targetPlaceholder)+import Tricorder.Session.Config (Config (..))+import Tricorder.Session.Repl (Repl (..))+import Tricorder.Session.Stage.Test.Command+ ( RenderedTestCommand (..)+ , render+ )+import Tricorder.Session.Stage.Test.Config (TestConfig (..))+import Tricorder.Session.Stage.Test.Session (TestSession (..), resolve)+import Tricorder.Session.TestTarget (TestTarget (..))++import Tricorder.Session.CommandConfig qualified as CommandConfig+import Tricorder.Session.Stage qualified as Stage+import Tricorder.Session.Target qualified as Target+++test_Command :: TestTree+test_Command =+ testGroup+ "Tricorder.Session.Stage.Test.Command"+ [ testResolveTestSession+ , testGroup "renderTest" testRenderTest+ ]+++testRenderTest :: [TestTree]+testRenderTest =+ [ testCase "substitutes {target} (singular) with the single test target" do+ test (CommandTemplate Cabal "cabal repl {target}" [] targetPlaceholder) Nothing testTarget+ @?= "cabal repl test:foo"+ , testCase "does not substitute {targets} (plural) when the command uses targetPlaceholder" do+ test (CommandTemplate Cabal "cabal repl {targets}" [] targetPlaceholder) Nothing testTarget+ @?= "cabal repl {targets}"+ , testCase "adds no memory-limit flag when no limit is configured" do+ test (CommandTemplate Cabal "cabal repl {target}" [] targetPlaceholder) Nothing testTarget+ @?= "cabal repl test:foo"+ , testCase "appends a --repl-options memory-limit RTS flag for Cabal" do+ test (CommandTemplate Cabal "cabal repl {target}" [] targetPlaceholder) (Just oneByte) testTarget+ @?= "cabal repl test:foo --repl-options +RTS -M1 -RTS"+ , testCase "appends a --ghc-options memory-limit RTS flag for Stack" do+ -- Plain (single-package) Stack renders bare component names, not+ -- the fully qualified target — see 'testRenderTargetsFor'.+ test (CommandTemplate Stack "stack ghci {target}" [] targetPlaceholder) (Just oneByte) testTarget+ @?= "stack ghci foo --ghc-options +RTS -M1 -RTS"+ , testCase "appends the memory-limit flag after the user's configured arguments" do+ test+ (CommandTemplate Cabal "cabal repl {target}" ["--flag"] targetPlaceholder)+ (Just oneByte)+ testTarget+ @?= "cabal repl test:foo --flag --repl-options +RTS -M1 -RTS"+ ]+ where+ test :: CommandTemplate 'Stage.Test -> Maybe ByteSize -> TestTarget -> Text+ test template mMemoryLimit target =+ (render (TestSession template [] def) mMemoryLimit target).command.getRenderedCommand+ testTarget = TestTarget (Target.parse "test:foo")+ oneByte = ByteSize 1 B+++testResolveTestSession :: TestTree+testResolveTestSession =+ testGroup+ "resolveTestSession"+ [ testCase "uses the built-in default template for Cabal when test.command_template is unset" do+ (resolve Cabal [] def).commandTemplate.template @?= "cabal repl {target}"+ , testCase "uses the built-in default template for Stack when test.command_template is unset" do+ (resolve Stack [] def).commandTemplate.template @?= "stack ghci {target}"+ , testCase "uses test.command_template when set" do+ let cfg =+ def+ { test =+ def+ { commandConfig =+ def+ { CommandConfig.commandTemplate = Just "cabal repl --repl-options=-fno-code {target}"+ }+ }+ }+ (resolve Cabal [] cfg).commandTemplate.template+ @?= "cabal repl --repl-options=-fno-code {target}"+ , testCase "carries test.extra_auto_arguments when test.command_template is unset" do+ let cfg = def {test = def {commandConfig = def {extraAutoArguments = ["--flag"]}}}+ (resolve Cabal [] cfg).commandTemplate.arguments @?= ["--flag"]+ , testCase "ignores test.extra_auto_arguments when test.command_template is set" do+ let cfg =+ def+ { test =+ def+ { commandConfig =+ def+ { commandTemplate = Just "cabal repl {target}"+ , extraAutoArguments = ["--flag"]+ }+ }+ }+ (resolve Cabal [] cfg).commandTemplate.arguments @?= []+ ]
test/Unit/Tricorder/Session/TargetSpec.hs view
@@ -1,24 +1,19 @@-module Unit.Tricorder.Session.TargetSpec (spec_Target) where+module Unit.Tricorder.Session.TargetSpec (test_Target) where import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)-import Test.Hspec- ( Spec- , describe- , it- , shouldBe- , shouldContain- , shouldMatchList- )+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))+import Prelude hiding (compare) import Tricorder.Session.CabalFile (CabalFile (..)) import Tricorder.Session.Target ( ComponentKind (..) , Target (..) , allComponentTargets- , compareTargets+ , compare , definesCustomPrelude- , parseTarget- , resolveTargets+ , parse+ , resolve ) import Unit.Tricorder.Session.Helpers ( gpd@@ -29,211 +24,246 @@ ) -spec_Target :: Spec-spec_Target = do- describe "resolveTargets" testResolveTargets- describe "parseTarget" testParseTarget- describe "compareTargets" testCompareTargets- describe "allComponentTargets" testAllComponentTargets- describe "definesCustomPrelude" testDefinesCustomPrelude+test_Target :: TestTree+test_Target =+ testGroup+ "Target"+ [ testGroup "resolve" testResolveTargets+ , testGroup "parse" testParseTarget+ , testGroup "compare" testCompareTargets+ , testGroup "allComponentTargets" testAllComponentTargets+ , testGroup "definesCustomPrelude" testDefinesCustomPrelude+ ] -testParseTarget :: Spec-testParseTarget = do- describe "qualified targets" do- it "parses lib: as the main library (empty name)" do- parseTarget "lib:" `shouldBe` Qualified Lib ""-- it "parses a named lib: target" do- parseTarget "lib:myapp-utils" `shouldBe` Qualified Lib "myapp-utils"-- it "parses an flib: target" do- parseTarget "flib:myapp-flib" `shouldBe` Qualified FLib "myapp-flib"-- it "parses an exe: target" do- parseTarget "exe:myapp-exe" `shouldBe` Qualified Exe "myapp-exe"-- it "parses a test: target" do- parseTarget "test:myapp-test" `shouldBe` Qualified Test "myapp-test"-- it "parses a bench: target" do- parseTarget "bench:myapp-bench" `shouldBe` Qualified Bench "myapp-bench"-- describe "a name with no kind prefix" do- it "parses as bare" do- parseTarget "myapp" `shouldBe` Bare "myapp"-- describe "unrecognized targets" do- it "rejects an unknown kind" do- parseTarget "bogus:myapp" `shouldBe` Unrecognized "bogus:myapp"-- it "rejects a form with extra colons" do- parseTarget "lib:a:b" `shouldBe` Unrecognized "lib:a:b"+testParseTarget :: [TestTree]+testParseTarget =+ [ testGroup+ "qualified targets"+ [ testCase "parses lib: as the main library (empty name)" do+ parse "lib:" @?= Qualified Lib ""+ , testCase "parses a named lib: target" do+ parse "lib:myapp-utils" @?= Qualified Lib "myapp-utils"+ , testCase "parses an flib: target" do+ parse "flib:myapp-flib" @?= Qualified FLib "myapp-flib"+ , testCase "parses an exe: target" do+ parse "exe:myapp-exe" @?= Qualified Exe "myapp-exe"+ , testCase "parses a test: target" do+ parse "test:myapp-test" @?= Qualified Test "myapp-test"+ , testCase "parses a bench: target" do+ parse "bench:myapp-bench" @?= Qualified Bench "myapp-bench"+ ]+ , testGroup+ "a name with no kind prefix"+ [ testCase "parses as bare" do+ parse "myapp" @?= Bare "myapp"+ ]+ , testGroup+ "unrecognized targets"+ [ testCase "rejects an unknown kind" do+ parse "bogus:myapp" @?= Unrecognized "bogus:myapp"+ , testCase "rejects a form with extra colons" do+ parse "lib:a:b" @?= Unrecognized "lib:a:b"+ ]+ ] -testResolveTargets :: Spec-testResolveTargets = do- describe "when targets are configured" do- it "parses and sorts configured targets" do- let actual = resolveTargets [] ["lib:foo", "test:foo-test"]- actual `shouldBe` [Qualified Lib "foo", Qualified Test "foo-test"]-- describe "when no targets are configured" do- it "auto-detects all components from the cabal file" do+testResolveTargets :: [TestTree]+testResolveTargets =+ [ testGroup+ "when targets are configured"+ [ testCase "parses and sorts configured targets" do+ let actual = resolve [] ["lib:foo", "test:foo-test"]+ actual @?= [Qualified Lib "foo", Qualified Test "foo-test"]+ ]+ , testGroup+ "when no targets are configured"+ [ testCase "auto-detects all components from the cabal file" do -- cabalFixture exposes no Prelude module, so all components sort -- alphabetically by their rendered form.- let actual = resolveTargets singleCabalFile []+ let actual = resolve singleCabalFile [] actual- `shouldBe` [ PackageQualified "myapp" Bench "myapp-bench"- , PackageQualified "myapp" Exe "myapp-exe"- , PackageQualified "myapp" FLib "myapp-flib"- , PackageQualified "myapp" Lib "myapp"- , PackageQualified "myapp" Lib "myapp-utils"- , PackageQualified "myapp" Test "myapp-test"- ]-- it "surfaces test-suite components so they can be run after a build" do- let actual = resolveTargets singleCabalFile []- actual `shouldContain` [PackageQualified "myapp" Test "myapp-test"]-- it "returns no targets when there are no cabal files" do- let actual = resolveTargets [] []- actual `shouldBe` []-- -- [tag: test_resolve_targest_aggregate]- it "aggregates components across every package (regression: was 0)" do- let actual = resolveTargets multiCabalFiles []+ @?= [ PackageQualified "myapp" Bench "myapp-bench"+ , PackageQualified "myapp" Exe "myapp-exe"+ , PackageQualified "myapp" FLib "myapp-flib"+ , PackageQualified "myapp" Lib "myapp"+ , PackageQualified "myapp" Lib "myapp-utils"+ , PackageQualified "myapp" Test "myapp-test"+ ]+ , testCase "surfaces test-suite components so they can be run after a build" do+ let actual = resolve singleCabalFile []+ assertBool "expected myapp-test among the targets"+ $ PackageQualified "myapp" Test "myapp-test" `elem` actual+ , testCase "returns no targets when there are no cabal files" do+ let actual = resolve [] []+ actual @?= []+ , -- [tag: test_resolve_targest_aggregate]+ testCase "aggregates components across every package (regression: was 0)" do+ let actual = resolve multiCabalFiles [] actual- `shouldMatchList` [ PackageQualified "pkg-a" Test "pkg-a-test"- , PackageQualified "pkg-b" Test "pkg-b-test"- , PackageQualified "pkg-a" Lib "pkg-a"- , PackageQualified "pkg-b" Lib "pkg-b"- ]-- it "sorts a library exposing a custom Prelude last" do+ @?= [ PackageQualified "pkg-a" Lib "pkg-a"+ , PackageQualified "pkg-a" Test "pkg-a-test"+ , PackageQualified "pkg-b" Lib "pkg-b"+ , PackageQualified "pkg-b" Test "pkg-b-test"+ ]+ , testCase "sorts a library exposing a custom Prelude last" do let cabalFile = CabalFile "/myprelude.cabal" $ fromMaybe (error "libWithPreludeCabal failed to parse") $ parseGenericPackageDescriptionMaybe (libWithPreludeCabal "myprelude")- let actual = resolveTargets [cabalFile] []+ let actual = resolve [cabalFile] [] actual- `shouldBe` [ PackageQualified "myprelude" Exe "myprelude-exe"- , PackageQualified "myprelude" Lib "myprelude"- ]+ @?= [ PackageQualified "myprelude" Exe "myprelude-exe"+ , PackageQualified "myprelude" Lib "myprelude"+ ]+ ]+ ] -testCompareTargets :: Spec-testCompareTargets = do+testCompareTargets :: [TestTree]+testCompareTargets = -- A predicate that stands in for 'definesCustomPrelude': marks lib: targets -- as "defines custom Prelude" so the comparison contract is exercised -- independently of cabal-file parsing.- let defPred (Qualified Lib _) = True- defPred _ = False-- describe "Ord" do- describe "only first target matches the predicate" do- describe "first target's render normally sorts as LT" do- it "should return GT" do- compareTargets defPred (Qualified Lib "a") (Qualified Exe "b") `shouldBe` GT- describe "both targets have the same render" do- it "should return GT" do- compareTargets defPred (Qualified Lib "a") (Qualified Exe "a") `shouldBe` GT- describe "first target's render normally sorts as GT" do- it "should return GT" do- compareTargets defPred (Qualified Lib "b") (Qualified Exe "a") `shouldBe` GT-- describe "only second target matches the predicate" do- describe "first target's render normally sorts as LT" do- it "should return LT" do- compareTargets defPred (Qualified Exe "a") (Qualified Lib "b") `shouldBe` LT- describe "both targets have the same render" do- it "should return LT" do- compareTargets defPred (Qualified Exe "a") (Qualified Lib "a") `shouldBe` LT- describe "first target's render normally sorts as GT" do- it "should return LT" do- compareTargets defPred (Qualified Exe "b") (Qualified Lib "a") `shouldBe` LT-- describe "both targets match the predicate" do- describe "first target's render normally sorts as LT" do- it "should sort normally" do- compareTargets defPred (Qualified Lib "a") (Qualified Lib "b") `shouldBe` LT- describe "both targets have the same render" do- it "should sort normally" do- compareTargets defPred (Qualified Lib "a") (Qualified Lib "a") `shouldBe` EQ- describe "first target's render normally sorts as GT" do- it "should sort normally" do- compareTargets defPred (Qualified Lib "b") (Qualified Lib "a") `shouldBe` GT-- describe "neither target matches the predicate" do- describe "first target's render normally sorts as LT" do- it "should sort normally" do- compareTargets defPred (Qualified Exe "a") (Qualified Exe "b") `shouldBe` LT- describe "both targets have the same render" do- it "should sort normally" do- compareTargets defPred (Qualified Exe "a") (Qualified Exe "a") `shouldBe` EQ- describe "first target's render normally sorts as GT" do- it "should sort normally" do- compareTargets defPred (Qualified Exe "b") (Qualified Exe "a") `shouldBe` GT+ [ testGroup+ "Ord"+ [ testGroup+ "only first target matches the predicate"+ [ testGroup+ "first target's render normally sorts as LT"+ [ testCase "should return GT" do+ compare defPred (Qualified Lib "a") (Qualified Exe "b") @?= GT+ ]+ , testGroup+ "both targets have the same render"+ [ testCase "should return GT" do+ compare defPred (Qualified Lib "a") (Qualified Exe "a") @?= GT+ ]+ , testGroup+ "first target's render normally sorts as GT"+ [ testCase "should return GT" do+ compare defPred (Qualified Lib "b") (Qualified Exe "a") @?= GT+ ]+ ]+ , testGroup+ "only second target matches the predicate"+ [ testGroup+ "first target's render normally sorts as LT"+ [ testCase "should return LT" do+ compare defPred (Qualified Exe "a") (Qualified Lib "b") @?= LT+ ]+ , testGroup+ "both targets have the same render"+ [ testCase "should return LT" do+ compare defPred (Qualified Exe "a") (Qualified Lib "a") @?= LT+ ]+ , testGroup+ "first target's render normally sorts as GT"+ [ testCase "should return LT" do+ compare defPred (Qualified Exe "b") (Qualified Lib "a") @?= LT+ ]+ ]+ , testGroup+ "both targets match the predicate"+ [ testGroup+ "first target's render normally sorts as LT"+ [ testCase "should sort normally" do+ compare defPred (Qualified Lib "a") (Qualified Lib "b") @?= LT+ ]+ , testGroup+ "both targets have the same render"+ [ testCase "should sort normally" do+ compare defPred (Qualified Lib "a") (Qualified Lib "a") @?= EQ+ ]+ , testGroup+ "first target's render normally sorts as GT"+ [ testCase "should sort normally" do+ compare defPred (Qualified Lib "b") (Qualified Lib "a") @?= GT+ ]+ ]+ , testGroup+ "neither target matches the predicate"+ [ testGroup+ "first target's render normally sorts as LT"+ [ testCase "should sort normally" do+ compare defPred (Qualified Exe "a") (Qualified Exe "b") @?= LT+ ]+ , testGroup+ "both targets have the same render"+ [ testCase "should sort normally" do+ compare defPred (Qualified Exe "a") (Qualified Exe "a") @?= EQ+ ]+ , testGroup+ "first target's render normally sorts as GT"+ [ testCase "should sort normally" do+ compare defPred (Qualified Exe "b") (Qualified Exe "a") @?= GT+ ]+ ]+ ]+ ]+ where+ defPred (Qualified Lib _) = True+ defPred _ = False -testAllComponentTargets :: Spec-testAllComponentTargets = do- it "returns every component for the fixture" do+testAllComponentTargets :: [TestTree]+testAllComponentTargets =+ [ testCase "returns every component for the fixture" do allComponentTargets gpd- `shouldMatchList` [ PackageQualified "myapp" Lib "myapp"- , PackageQualified "myapp" Lib "myapp-utils"- , PackageQualified "myapp" FLib "myapp-flib"- , PackageQualified "myapp" Exe "myapp-exe"- , PackageQualified "myapp" Test "myapp-test"- , PackageQualified "myapp" Bench "myapp-bench"- ]- -- This test ensures `allComponentTargets`' part of the aggregate test.- -- [ref:test_resolve_targest_aggregate]- it "returns every component for test fixures" do+ @?= [ PackageQualified "myapp" Lib "myapp"+ , PackageQualified "myapp" Lib "myapp-utils"+ , PackageQualified "myapp" FLib "myapp-flib"+ , PackageQualified "myapp" Exe "myapp-exe"+ , PackageQualified "myapp" Test "myapp-test"+ , PackageQualified "myapp" Bench "myapp-bench"+ ]+ , -- This test ensures `allComponentTargets`' part of the aggregate test.+ -- [ref:test_resolve_targest_aggregate]+ testCase "returns every component for test fixures" do let actual = allComponentTargets $ fromMaybe (error "failed to parse cabal") $ parseGenericPackageDescriptionMaybe $ libTestCabal "pkg-a" actual- `shouldMatchList` [ PackageQualified "pkg-a" Lib "pkg-a"- , PackageQualified "pkg-a" Test "pkg-a-test"- ]+ @?= [ PackageQualified "pkg-a" Lib "pkg-a"+ , PackageQualified "pkg-a" Test "pkg-a-test"+ ]+ ] -testDefinesCustomPrelude :: Spec-testDefinesCustomPrelude = do- let preludeCF =- CabalFile "/myprelude.cabal"- $ fromMaybe (error "libWithPreludeCabal failed to parse")- $ parseGenericPackageDescriptionMaybe (libWithPreludeCabal "myprelude")-- describe "when the main library exposes Prelude" do- it "returns True for Qualified Lib \"\" (unnamed main lib)" do- definesCustomPrelude [preludeCF] (Qualified Lib "") `shouldBe` True-- it "returns True for Qualified Lib matching the package name" do- definesCustomPrelude [preludeCF] (Qualified Lib "myprelude") `shouldBe` True-- it "returns True for Bare matching the package name" do- definesCustomPrelude [preludeCF] (Bare "myprelude") `shouldBe` True-- describe "when no library exposes Prelude" do- it "returns False for a lib target in a normal package" do- definesCustomPrelude singleCabalFile (Qualified Lib "myapp") `shouldBe` False-- it "returns False for Bare matching the package name" do- definesCustomPrelude singleCabalFile (Bare "myapp") `shouldBe` False-- describe "for non-library targets" do- it "returns False for Qualified Exe" do- definesCustomPrelude [preludeCF] (Qualified Exe "myprelude-exe") `shouldBe` False-- it "returns False for Qualified Test" do- definesCustomPrelude singleCabalFile (Qualified Test "myapp-test") `shouldBe` False-- it "returns False for Unrecognized" do- definesCustomPrelude [preludeCF] (Unrecognized "library:myprelude") `shouldBe` False-- it "returns False when the cabal file list is empty" do- definesCustomPrelude [] (Qualified Lib "anything") `shouldBe` False+testDefinesCustomPrelude :: [TestTree]+testDefinesCustomPrelude =+ [ testGroup+ "when the main library exposes Prelude"+ [ testCase "returns True for Qualified Lib \"\" (unnamed main lib)" do+ definesCustomPrelude [preludeCF] (Qualified Lib "") @?= True+ , testCase "returns True for Qualified Lib matching the package name" do+ definesCustomPrelude [preludeCF] (Qualified Lib "myprelude") @?= True+ , testCase "returns True for Bare matching the package name" do+ definesCustomPrelude [preludeCF] (Bare "myprelude") @?= True+ ]+ , testGroup+ "when no library exposes Prelude"+ [ testCase "returns False for a lib target in a normal package" do+ definesCustomPrelude singleCabalFile (Qualified Lib "myapp") @?= False+ , testCase "returns False for Bare matching the package name" do+ definesCustomPrelude singleCabalFile (Bare "myapp") @?= False+ ]+ , testGroup+ "for non-library targets"+ [ testCase "returns False for Qualified Exe" do+ definesCustomPrelude [preludeCF] (Qualified Exe "myprelude-exe") @?= False+ , testCase "returns False for Qualified Test" do+ definesCustomPrelude singleCabalFile (Qualified Test "myapp-test") @?= False+ , testCase "returns False for Unrecognized" do+ definesCustomPrelude [preludeCF] (Unrecognized "library:myprelude") @?= False+ ]+ , testCase "returns False when the cabal file list is empty" do+ definesCustomPrelude [] (Qualified Lib "anything") @?= False+ ]+ where+ preludeCF =+ CabalFile "/myprelude.cabal"+ $ fromMaybe (error "libWithPreludeCabal failed to parse")+ $ parseGenericPackageDescriptionMaybe (libWithPreludeCabal "myprelude")
test/Unit/Tricorder/Session/TestTargetSpec.hs view
@@ -1,43 +1,45 @@-module Unit.Tricorder.Session.TestTargetSpec (spec_TestTarget) where+module Unit.Tricorder.Session.TestTargetSpec (test_TestTarget) where import Data.Default (def)-import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Session.Config (Config (..))-import Tricorder.Session.Target (parseTarget)-import Tricorder.Session.TestTarget (parseTestTargets, resolveTestTargets) +import Tricorder.Session.Target qualified as Target+import Tricorder.Session.TestTarget qualified as TestTarget -spec_TestTarget :: Spec-spec_TestTarget = do- describe "resolveTestTargets" testResolveTestTargets +test_TestTarget :: TestTree+test_TestTarget =+ testGroup+ "TestTarget"+ [ testGroup "resolveTestTargets" testResolveTestTargets+ ] -testResolveTestTargets :: Spec-testResolveTestTargets = do- it "infers test: components from targets when testTargets is absent" do- let cfg = def :: Config- resolveTestTargets cfg (mkTargets ["lib:mylib", "test:mylib-test"])- `shouldBe` parseTestTargets ["test:mylib-test"] - it "returns empty list when no test: components in targets" do+testResolveTestTargets :: [TestTree]+testResolveTestTargets =+ [ testCase "infers test: components from targets when testTargets is absent" do let cfg = def :: Config- resolveTestTargets cfg (mkTargets ["lib:mylib", "exe:myapp"])- `shouldBe` parseTestTargets []-- it "uses explicit testTargets list when set" do+ TestTarget.resolve cfg (mkTargets ["lib:mylib", "test:mylib-test"])+ @?= TestTarget.parse ["test:mylib-test"]+ , testCase "returns empty list when no test: components in targets" do+ let cfg = def :: Config+ TestTarget.resolve cfg (mkTargets ["lib:mylib", "exe:myapp"])+ @?= TestTarget.parse []+ , testCase "uses explicit testTargets list when set" do let cfg = def {testTargets = Just ["test:b-test"]} :: Config- resolveTestTargets cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])- `shouldBe` parseTestTargets ["test:b-test"]-- it "returns empty list when testTargets is explicitly empty" do+ TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])+ @?= TestTarget.parse ["test:b-test"]+ , testCase "returns empty list when testTargets is explicitly empty" do let cfg = def {testTargets = Just []} :: Config- resolveTestTargets cfg (mkTargets ["lib:a", "test:a-test"])- `shouldBe` parseTestTargets []-- it "infers multiple test: components" do+ TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test"])+ @?= TestTarget.parse []+ , testCase "infers multiple test: components" do let cfg = def :: Config- resolveTestTargets cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])- `shouldBe` parseTestTargets ["test:a-test", "test:b-test"]+ TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])+ @?= TestTarget.parse ["test:a-test", "test:b-test"]+ ] where- mkTargets = fmap parseTarget+ mkTargets = fmap Target.parse
test/Unit/Tricorder/Session/WatchDirsSpec.hs view
@@ -1,142 +1,167 @@-module Unit.Tricorder.Session.WatchDirsSpec (spec_WatchDirs) where+module Unit.Tricorder.Session.WatchDirsSpec (test_WatchDirs) where import Data.Default (def)-import Test.Hspec (Spec, context, describe, it, shouldBe)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.Config (Config (..))-import Tricorder.Session.Target (ComponentKind (..), Target (..), parseTarget)-import Tricorder.Session.WatchDirs (WatchDirs (..), resolveWatchDirs, sourceDirsForTarget)+import Tricorder.Session.Target (ComponentKind (..), Target (..))+import Tricorder.Session.WatchDirs (WatchDirs (..), sourceDirsForTarget) import Unit.Tricorder.Session.Helpers (gpd, multiCabalFiles, singleCabalFile) +import Tricorder.Session.Target qualified as Target+import Tricorder.Session.WatchDirs qualified as WatchDirs -spec_WatchDirs :: Spec-spec_WatchDirs = do- describe "resolveWatchDirs" testResolveWatchDirs- describe "sourceDirsForTarget" testSourceDirsForTarget +test_WatchDirs :: TestTree+test_WatchDirs =+ testGroup+ "WatchDirs"+ [ testGroup "resolveWatchDirs" testResolveWatchDirs+ , testGroup "sourceDirsForTarget" testSourceDirsForTarget+ ] -testResolveWatchDirs :: Spec-testResolveWatchDirs = do- describe "when watch_dirs is set in config" do- it "uses config dirs relative to project root" do- let WatchDirs actual =- resolveWatchDirs pr [] def {watchDirs = ["src", "test"]} []- actual `shouldBe` ["/src", "/test"] - describe "when watch_dirs is not set" do- it "falls back to [\".\"] when targets list is empty" do- let WatchDirs actual = resolveWatchDirs pr [] def []- actual `shouldBe` ["."]-- it "infers source dirs from resolved targets" do+testResolveWatchDirs :: [TestTree]+testResolveWatchDirs =+ [ testGroup+ "when watch_dirs is set in config"+ [ testCase "uses config dirs relative to project root" do let WatchDirs actual =- resolveWatchDirs pr singleCabalFile def (mkTargets ["lib:myapp", "test:myapp-test"])- actual `shouldBe` ["/src", "/test"]-- it "falls back to [\".\"] when there are no cabal files" do+ WatchDirs.resolve pr [] def {watchDirs = ["src", "test"]} []+ actual @?= ["/src", "/test"]+ ]+ , testGroup+ "when watch_dirs is not set"+ [ testCase "falls back to [\".\"] when targets list is empty" do+ let WatchDirs actual = WatchDirs.resolve pr [] def []+ actual @?= ["."]+ , testCase "infers source dirs from resolved targets" do let WatchDirs actual =- resolveWatchDirs pr [] def (mkTargets ["lib:myapp"])- actual `shouldBe` ["."]-- -- Sharp edge: an unparseable .cabal yields no source dirs, so resolution- -- falls back to watching the whole project root. This pins the current- -- behavior; if it ever changes to something narrower, update this test.- it "falls back to [\".\"] when no cabal files are found or parsed" do+ WatchDirs.resolve pr singleCabalFile def (mkTargets ["lib:myapp", "test:myapp-test"])+ actual @?= ["/src", "/test"]+ , testCase "falls back to [\".\"] when there are no cabal files" do let WatchDirs actual =- resolveWatchDirs pr [] def (mkTargets ["lib:myapp"])- actual `shouldBe` ["."]-- describe "when the project is a multi-package cabal.project" do- it "infers per-package source dirs, scoped to each package's directory" do+ WatchDirs.resolve pr [] def (mkTargets ["lib:myapp"])+ actual @?= ["."]+ , -- Sharp edge: an unparseable .cabal yields no source dirs, so resolution+ -- falls back to watching the whole project root. This pins the current+ -- behavior; if it ever changes to something narrower, update this test.+ testCase "falls back to [\".\"] when no cabal files are found or parsed" do let WatchDirs actual =- resolveWatchDirs+ WatchDirs.resolve pr [] def (mkTargets ["lib:myapp"])+ actual @?= ["."]+ ]+ , testGroup+ "when the project is a multi-package cabal.project"+ [ testCase "infers per-package source dirs, scoped to each package's directory" do+ let WatchDirs actual =+ WatchDirs.resolve pr multiCabalFiles def (mkTargets ["lib:pkg-a", "test:pkg-a-test", "lib:pkg-b", "test:pkg-b-test"]) actual- `shouldBe` ["/pkg-a/src", "/pkg-a/test", "/pkg-b/src", "/pkg-b/test"]-- it "scopes a bare package-name target to that package, ignoring siblings" do+ @?= ["/pkg-a/src", "/pkg-a/test", "/pkg-b/src", "/pkg-b/test"]+ , testCase "scopes a bare package-name target to that package, ignoring siblings" do let WatchDirs actual =- resolveWatchDirs pr multiCabalFiles def (mkTargets ["pkg-a"])- actual `shouldBe` ["/pkg-a/src", "/pkg-a/test"]+ WatchDirs.resolve pr multiCabalFiles def (mkTargets ["pkg-a"])+ actual @?= ["/pkg-a/src", "/pkg-a/test"]+ ]+ ] where pr = ProjectRoot "/" -- | These exercise the 'Target' -> dirs resolution directly with constructed -- 'Target' values; the string -> 'Target' parsing is covered by 'testParseTarget'.-testSourceDirsForTarget :: Spec-testSourceDirsForTarget = do- describe "Qualified Lib" do- context "when the name is empty" do- it "returns the main library source dirs" do- sourceDirsForTarget gpd (Qualified Lib "") `shouldBe` ["src"]-- context "when the name matches the package name" do- it "returns the main library source dirs" do- sourceDirsForTarget gpd (Qualified Lib "myapp") `shouldBe` ["src"]-- context "when the name matches a sub-library" do- it "returns the sub-library source dirs" do- sourceDirsForTarget gpd (Qualified Lib "myapp-utils") `shouldBe` ["utils"]-- context "when the sub-library is unknown" do- it "returns an empty list" do- sourceDirsForTarget gpd (Qualified Lib "nonexistent") `shouldBe` []-- describe "Qualified FLib" do- it "returns the foreign-library source dirs" do- sourceDirsForTarget gpd (Qualified FLib "myapp-flib") `shouldBe` ["flib"]-- describe "Qualified Exe" do- it "returns the executable source dirs" do- sourceDirsForTarget gpd (Qualified Exe "myapp-exe") `shouldBe` ["app"]-- describe "Qualified Test" do- it "returns the test suite source dirs" do- sourceDirsForTarget gpd (Qualified Test "myapp-test") `shouldBe` ["test"]-- describe "Qualified Bench" do- it "returns the benchmark source dirs" do- sourceDirsForTarget gpd (Qualified Bench "myapp-bench") `shouldBe` ["bench"]-- describe "Bare (package name)" do- it "returns every component's source dirs" do- sourceDirsForTarget gpd (Bare "myapp") `shouldBe` ["src", "utils", "flib", "app", "test", "bench"]-- describe "Bare (component name)" do- context "when it names a sub-library" do- it "returns the sub-library source dirs" do- sourceDirsForTarget gpd (Bare "myapp-utils") `shouldBe` ["utils"]-- context "when it names an executable" do- it "returns the executable source dirs" do- sourceDirsForTarget gpd (Bare "myapp-exe") `shouldBe` ["app"]-- context "when it names a test suite" do- it "returns the test suite source dirs" do- sourceDirsForTarget gpd (Bare "myapp-test") `shouldBe` ["test"]-- context "when it matches no component" do- it "returns an empty list" do- sourceDirsForTarget gpd (Bare "unknown") `shouldBe` []-- describe "Unrecognized" do- it "matches an aliased kind prefix by trailing name" do- sourceDirsForTarget gpd (Unrecognized "executable:myapp-exe") `shouldBe` ["app"]-- it "matches a case-variant kind prefix by trailing name" do- sourceDirsForTarget gpd (Unrecognized "Test-Suite:myapp-test") `shouldBe` ["test"]-- it "matches the main library when the trailing name is the package name" do- sourceDirsForTarget gpd (Unrecognized "library:myapp") `shouldBe` ["src"]-- it "returns an empty list when the trailing name matches no component" do- sourceDirsForTarget gpd (Unrecognized "bogus:x") `shouldBe` []+testSourceDirsForTarget :: [TestTree]+testSourceDirsForTarget =+ [ testGroup+ "Qualified Lib"+ [ testGroup+ "when the name is empty"+ [ testCase "returns the main library source dirs" do+ sourceDirsForTarget gpd (Qualified Lib "") @?= ["src"]+ ]+ , testGroup+ "when the name matches the package name"+ [ testCase "returns the main library source dirs" do+ sourceDirsForTarget gpd (Qualified Lib "myapp") @?= ["src"]+ ]+ , testGroup+ "when the name matches a sub-library"+ [ testCase "returns the sub-library source dirs" do+ sourceDirsForTarget gpd (Qualified Lib "myapp-utils") @?= ["utils"]+ ]+ , testGroup+ "when the sub-library is unknown"+ [ testCase "returns an empty list" do+ sourceDirsForTarget gpd (Qualified Lib "nonexistent") @?= []+ ]+ ]+ , testGroup+ "Qualified FLib"+ [ testCase "returns the foreign-library source dirs" do+ sourceDirsForTarget gpd (Qualified FLib "myapp-flib") @?= ["flib"]+ ]+ , testGroup+ "Qualified Exe"+ [ testCase "returns the executable source dirs" do+ sourceDirsForTarget gpd (Qualified Exe "myapp-exe") @?= ["app"]+ ]+ , testGroup+ "Qualified Test"+ [ testCase "returns the test suite source dirs" do+ sourceDirsForTarget gpd (Qualified Test "myapp-test") @?= ["test"]+ ]+ , testGroup+ "Qualified Bench"+ [ testCase "returns the benchmark source dirs" do+ sourceDirsForTarget gpd (Qualified Bench "myapp-bench") @?= ["bench"]+ ]+ , testGroup+ "Bare (package name)"+ [ testCase "returns every component's source dirs" do+ sourceDirsForTarget gpd (Bare "myapp") @?= ["src", "utils", "flib", "app", "test", "bench"]+ ]+ , testGroup+ "Bare (component name)"+ [ testGroup+ "when it names a sub-library"+ [ testCase "returns the sub-library source dirs" do+ sourceDirsForTarget gpd (Bare "myapp-utils") @?= ["utils"]+ ]+ , testGroup+ "when it names an executable"+ [ testCase "returns the executable source dirs" do+ sourceDirsForTarget gpd (Bare "myapp-exe") @?= ["app"]+ ]+ , testGroup+ "when it names a test suite"+ [ testCase "returns the test suite source dirs" do+ sourceDirsForTarget gpd (Bare "myapp-test") @?= ["test"]+ ]+ , testGroup+ "when it matches no component"+ [ testCase "returns an empty list" do+ sourceDirsForTarget gpd (Bare "unknown") @?= []+ ]+ ]+ , testGroup+ "Unrecognized"+ [ testCase "matches an aliased kind prefix by trailing name" do+ sourceDirsForTarget gpd (Unrecognized "executable:myapp-exe") @?= ["app"]+ , testCase "matches a case-variant kind prefix by trailing name" do+ sourceDirsForTarget gpd (Unrecognized "Test-Suite:myapp-test") @?= ["test"]+ , testCase "matches the main library when the trailing name is the package name" do+ sourceDirsForTarget gpd (Unrecognized "library:myapp") @?= ["src"]+ , testCase "returns an empty list when the trailing name matches no component" do+ sourceDirsForTarget gpd (Unrecognized "bogus:x") @?= []+ ]+ ] mkTargets :: [Text] -> [Target]-mkTargets = fmap parseTarget+mkTargets = fmap Target.parse
test/Unit/Tricorder/SessionSpec.hs view
@@ -1,4 +1,4 @@-module Unit.Tricorder.SessionSpec (spec_Session) where+module Unit.Tricorder.SessionSpec (test_Session) where import Atelier.Config (LoadedConfig (..)) import Atelier.Effects.FileSystem (runFileSystemState)@@ -10,7 +10,8 @@ import Effectful.Reader.Static (runReader) import Effectful.State.Static.Shared (evalState) import Effectful.Writer.Static.Shared (execWriter)-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session (Session (..), loadSession)@@ -19,27 +20,36 @@ import Unit.Tricorder.Session.Helpers (libWithPreludeCabal, preludeOnlyLibCabal) -spec_Session :: Spec-spec_Session = do- describe "loadSession" testLoadSession- describe "loadSession idleTimeout" testIdleTimeout+test_Session :: TestTree+test_Session =+ testGroup+ "Session"+ [ testLoadSession+ , testIdleTimeout+ , testMissingTargetPlaceholder+ ] -testLoadSession :: Spec-testLoadSession = do- describe "when every resolved target exposes a custom Prelude module" do- it "emits a WARN" do- let msgs = captureSessionLogs [preludeOnlyCF]- any (\m -> m.severity == WARN) msgs `shouldBe` True-- describe "when not every resolved target exposes a custom Prelude module" do- it "does not emit a WARN" do- -- libWithPreludeCabal has both a lib (custom Prelude) and an exe (no Prelude)- let msgs = captureSessionLogs [mixedCF]- any (\m -> m.severity == WARN) msgs `shouldBe` False-- it "does not emit a WARN when there are no resolved targets" do- any (\m -> m.severity == WARN) (captureSessionLogs []) `shouldBe` False+testLoadSession :: TestTree+testLoadSession =+ testGroup+ "loadSession"+ [ testGroup+ "when every resolved target exposes a custom Prelude module"+ [ testCase "emits a WARN" do+ let msgs = captureSessionLogs [preludeOnlyCF]+ any (\m -> m.severity == WARN) msgs @?= True+ ]+ , testGroup+ "when not every resolved target exposes a custom Prelude module"+ [ testCase "does not emit a WARN" do+ -- libWithPreludeCabal has both a lib (custom Prelude) and an exe (no Prelude)+ let msgs = captureSessionLogs [mixedCF]+ any (\m -> m.severity == WARN) msgs @?= False+ ]+ , testCase "does not emit a WARN when there are no resolved targets" do+ any (\m -> m.severity == WARN) (captureSessionLogs []) @?= False+ ] where preludeOnlyCF = CabalFile "/p.cabal"@@ -61,14 +71,59 @@ $ loadSession -testIdleTimeout :: Spec-testIdleTimeout = do- it "defaults to 300 seconds when unset" do- (loadSessionWith (LoadedConfig Null)).idleTimeout `shouldBe` IdleTimeout 300+testMissingTargetPlaceholder :: TestTree+testMissingTargetPlaceholder =+ testGroup+ "loadSession missing {target} placeholder"+ [ testGroup+ "test.command_template"+ [ testCase "emits a WARN when it has no {target} placeholder" do+ let cfg = sessionCfg ["test" .= object ["command_template" .= ("cabal repl test:foo" :: Text)]]+ any (\m -> m.severity == WARN) (captureLogsFor cfg) @?= True+ , testCase "does not emit a WARN when it has the {target} placeholder" do+ let cfg = sessionCfg ["test" .= object ["command_template" .= ("cabal repl {target}" :: Text)]]+ any (\m -> m.severity == WARN) (captureLogsFor cfg) @?= False+ ]+ , testGroup+ "eval.command_template"+ [ testCase "emits a WARN when it has no {target} placeholder" do+ let cfg = sessionCfg ["eval" .= object ["command_template" .= ("cabal repl Tricorder.Foo" :: Text)]]+ any (\m -> m.severity == WARN) (captureLogsFor cfg) @?= True+ , testCase "does not emit a WARN when it has the {target} placeholder" do+ let cfg = sessionCfg ["eval" .= object ["command_template" .= ("cabal repl {target}" :: Text)]]+ any (\m -> m.severity == WARN) (captureLogsFor cfg) @?= False+ ]+ , testGroup+ "build.command_template"+ [ testCase "does not emit a WARN when it has no {targets} placeholder" do+ let cfg = sessionCfg ["build" .= object ["command_template" .= ("cabal repl lib:foo" :: Text)]]+ any (\m -> m.severity == WARN) (captureLogsFor cfg) @?= False+ ]+ ]+ where+ sessionCfg session = LoadedConfig $ object ["session" .= object session]+ captureLogsFor cfg =+ runPureEff+ . execWriter @[Message]+ . runLogWriter+ . evalState @(Map FilePath ByteString) mempty+ . runFileSystemState+ . runInputConst ([] :: [CabalFile])+ . runReader (ProjectRoot "/")+ . runInputConst cfg+ $ loadSession - it "reads idle_timeout_seconds from the session config" do- let cfg = LoadedConfig $ object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]- (loadSessionWith cfg).idleTimeout `shouldBe` IdleTimeout 5++testIdleTimeout :: TestTree+testIdleTimeout =+ testGroup+ "loadSession idleTimeout"+ [ testCase "defaults to 300 seconds when unset" do+ (loadSessionWith (LoadedConfig Null)).idleTimeout @?= IdleTimeout 300+ , testCase "reads idle_timeout_seconds from the session config" do+ let cfg = LoadedConfig $ object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]+ (loadSessionWith cfg).idleTimeout @?= IdleTimeout 5+ ] where loadSessionWith cfg = runPureEff
test/Unit/Tricorder/SocketSpec.hs view
@@ -1,9 +1,10 @@-module Unit.Tricorder.SocketSpec (spec_Socket) where+module Unit.Tricorder.SocketSpec (test_Socket) where import Atelier.Effects.File (File, runFile) import Effectful (IOE, runEff) import System.IO (hClose, hGetLine, openFile, writeFile)-import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.Socket.Client (isDaemonReady) import Tricorder.Socket.UnixSocket@@ -18,34 +19,39 @@ ) -spec_Socket :: Spec-spec_Socket = do- describe "runUnixSocketScripted" testScripted- describe "isDaemonReady" testReady+test_Socket :: TestTree+test_Socket =+ testGroup+ "Socket"+ [ testGroup "runUnixSocketScripted" testScripted+ , testGroup "isDaemonReady" testReady+ ] -------------------------------------------------------------------------------- -- Scripted interpreter tests -------------------------------------------------------------------------------- -testScripted :: Spec-testScripted = do- describe "socketFileExists" do- it "returns True when scripted" do+testScripted :: [TestTree]+testScripted =+ [ testGroup+ "socketFileExists"+ [ testCase "returns True when scripted" do result <- runScripted [NextFileCheck True] $ socketFileExists "/"- result `shouldBe` True-- it "returns False when scripted" do+ result @?= True+ , testCase "returns False when scripted" do result <- runScripted [NextFileCheck False] $ socketFileExists "/"- result `shouldBe` False-- describe "removeSocketFile" do- it "is always a no-op" do+ result @?= False+ ]+ , testGroup+ "removeSocketFile"+ [ testCase "is always a no-op" do -- No NextFileCheck/NextAccept needed; just returns () runScripted [] $ removeSocketFile "/nonexistent/path"-- describe "acceptHandle" do- it "returns the scripted handle, readable from a file" do+ ]+ , testGroup+ "acceptHandle"+ [ testCase "returns the scripted handle, readable from a file" do let tmpPath = "/tmp/tricorder-socket-accept-test.txt" writeFile tmpPath "hello from test\n" h <- liftIO $ openFile tmpPath ReadMode@@ -54,29 +60,31 @@ h' <- acceptHandle sock liftIO $ hGetLine h' liftIO $ hClose h- line `shouldBe` "hello from test"+ line @?= "hello from test"+ ]+ ] -------------------------------------------------------------------------------- -- isDaemonReady (real IO interpreter) -------------------------------------------------------------------------------- -testReady :: Spec-testReady = do- it "returns False when nothing is listening on the path" do+testReady :: [TestTree]+testReady =+ [ testCase "returns False when nothing is listening on the path" do -- A connect to a non-existent socket must be caught, not thrown: this is -- the race the start/status path hit before the socket was bound. result <- runIO' $ isDaemonReady "/tmp/tricorder-isdaemonready-absent.sock"- result `shouldBe` False-- it "returns True once a socket is bound and listening" do+ result @?= False+ , testCase "returns True once a socket is bound and listening" do let path = "/tmp/tricorder-isdaemonready-bound.sock" result <- runIO' do removeSocketFile path _ <- bindSocket path isDaemonReady path runIO' $ removeSocketFile path- result `shouldBe` True+ result @?= True+ ] --------------------------------------------------------------------------------
test/Unit/Tricorder/SourceLookup/GhcPkgSpec.hs view
@@ -1,32 +1,35 @@-module Unit.Tricorder.SourceLookup.GhcPkgSpec (spec_GhcPkg) where+module Unit.Tricorder.SourceLookup.GhcPkgSpec (test_GhcPkg) where import Effectful (runPureEff)-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) -import Tricorder.Session.Command (Repl (..))+import Tricorder.Session.Repl (Repl (..)) import Tricorder.SourceLookup.GhcPkg (GhcPkg, GhcPkgScript (..), findModule, runGhcPkgScripted) -spec_GhcPkg :: Spec-spec_GhcPkg = do- describe "findModule" testFindModule+test_GhcPkg :: TestTree+test_GhcPkg =+ testGroup+ "GhcPkg"+ [ testGroup "findModule" testFindModule+ ] -testFindModule :: Spec-testFindModule = do- it "returns Just pkgId when module is known" do+testFindModule :: [TestTree]+testFindModule =+ [ testCase "returns Just pkgId when module is known" do let result = runScripted [NextFindModule (Just "base-4.18")] $ findModule Cabal "Prelude"- result `shouldBe` Just "base-4.18"-- it "returns Nothing for an unknown module" do+ result @?= Just "base-4.18"+ , testCase "returns Nothing for an unknown module" do let result = runScripted [NextFindModule Nothing] $ findModule Cabal "No.Such.Module"- result `shouldBe` Nothing-- it "returns the first scripted result" do+ result @?= Nothing+ , testCase "returns the first scripted result" do let result = runScripted [NextFindModule (Just "pkg-1.0"), NextFindModule (Just "pkg-2.0")] $ findModule Cabal "Foo"- result `shouldBe` Just "pkg-1.0"+ result @?= Just "pkg-1.0"+ ] runScripted :: [GhcPkgScript] -> Eff '[GhcPkg] a -> a
test/Unit/Tricorder/SourceLookup/SliceSpec.hs view
@@ -1,20 +1,27 @@-module Unit.Tricorder.SourceLookup.SliceSpec (spec_Slice) where+module Unit.Tricorder.SourceLookup.SliceSpec (test_Slice) where -import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=)) import Data.Text qualified as T import Tricorder.SourceLookup.Slice (sliceSymbol) -spec_Slice :: Spec-spec_Slice = describe "sliceSymbol" do- valueBindings- typeDeclarations- constructors- compactDeclarations- robustness- capturePrecision+test_Slice :: TestTree+test_Slice =+ testGroup+ "Slice"+ [ testGroup+ "sliceSymbol"+ [ valueBindings+ , typeDeclarations+ , constructors+ , compactDeclarations+ , robustness+ , capturePrecision+ ]+ ] -- | Build a source fixture from individual lines.@@ -22,321 +29,314 @@ src = T.unlines -valueBindings :: Spec-valueBindings = describe "value bindings" do- it "slices a function with its signature and doc block" do- let source =- src- [ "-- | The answer to everything."- , "answer :: Int"- , "answer = 42"- , ""- , "other :: Bool"- , "other = True"- ]- sliceSymbol "answer" source- `shouldBe` Just "-- | The answer to everything.\nanswer :: Int\nanswer = 42"-- it "slices a binding with no signature" do- let source = src ["foo = 1", "", "bar = 2"]- sliceSymbol "foo" source `shouldBe` Just "foo = 1"-- it "captures every equation of a multi-equation binding" do- let source =- src- [ "isJust :: Maybe a -> Bool"- , "isJust (Just _) = True"- , "isJust Nothing = False"- , ""- , "next = ()"- ]- sliceSymbol "isJust" source- `shouldBe` Just "isJust :: Maybe a -> Bool\nisJust (Just _) = True\nisJust Nothing = False"-- it "keeps a multi-line line-comment doc block" do- let source =- src- [ "-- | The answer to everything,"- , "-- computed once."- , "answer :: Int"- , "answer = 42"- ]- sliceSymbol "answer" source- `shouldBe` Just "-- | The answer to everything,\n-- computed once.\nanswer :: Int\nanswer = 42"-- it "slices an operator binding in (op) form" do- let source =- src- [ "(<+>) :: Int -> Int -> Int"- , "a <+> b = a + b"- ]- sliceSymbol "<+>" source- `shouldBe` Just "(<+>) :: Int -> Int -> Int\na <+> b = a + b"-- it "does not match a different binding with a shared prefix" do- let source = src ["answer = 1", "", "answerable = 2"]- sliceSymbol "answerable" source `shouldBe` Just "answerable = 2"+valueBindings :: TestTree+valueBindings =+ testGroup+ "value bindings"+ [ testCase "slices a function with its signature and doc block" do+ let source =+ src+ [ "-- | The answer to everything."+ , "answer :: Int"+ , "answer = 42"+ , ""+ , "other :: Bool"+ , "other = True"+ ]+ sliceSymbol "answer" source+ @?= Just "-- | The answer to everything.\nanswer :: Int\nanswer = 42"+ , testCase "slices a binding with no signature" do+ let source = src ["foo = 1", "", "bar = 2"]+ sliceSymbol "foo" source @?= Just "foo = 1"+ , testCase "captures every equation of a multi-equation binding" do+ let source =+ src+ [ "isJust :: Maybe a -> Bool"+ , "isJust (Just _) = True"+ , "isJust Nothing = False"+ , ""+ , "next = ()"+ ]+ sliceSymbol "isJust" source+ @?= Just "isJust :: Maybe a -> Bool\nisJust (Just _) = True\nisJust Nothing = False"+ , testCase "keeps a multi-line line-comment doc block" do+ let source =+ src+ [ "-- | The answer to everything,"+ , "-- computed once."+ , "answer :: Int"+ , "answer = 42"+ ]+ sliceSymbol "answer" source+ @?= Just "-- | The answer to everything,\n-- computed once.\nanswer :: Int\nanswer = 42"+ , testCase "slices an operator binding in (op) form" do+ let source =+ src+ [ "(<+>) :: Int -> Int -> Int"+ , "a <+> b = a + b"+ ]+ sliceSymbol "<+>" source+ @?= Just "(<+>) :: Int -> Int -> Int\na <+> b = a + b"+ , testCase "does not match a different binding with a shared prefix" do+ let source = src ["answer = 1", "", "answerable = 2"]+ sliceSymbol "answerable" source @?= Just "answerable = 2"+ ] -- | Real Hackage source frequently packs top-level declarations together with -- no blank line between them. The slice must stop at the neighbouring -- declaration, not swallow it.-compactDeclarations :: Spec-compactDeclarations = describe "adjacent declarations without blank lines" do- it "does not swallow the following binding" do- sliceSymbol "bar" (src ["foo = 1", "bar = 2", "baz = 3"])- `shouldBe` Just "bar = 2"-- it "does not swallow the preceding binding and its signature" do- let source =- src- [ "foo :: Int"- , "foo = 1"- , "bar :: Int"- , "bar = 2"- ]- sliceSymbol "bar" source `shouldBe` Just "bar :: Int\nbar = 2"-- it "keeps the doc block but not a preceding declaration" do- let source =- src- [ "foo = 1"- , "-- | doc for bar"- , "bar = 2"- ]- sliceSymbol "bar" source `shouldBe` Just "-- | doc for bar\nbar = 2"-- it "keeps a multi-line doc block but not a preceding declaration" do- let source =- src- [ "foo = 1"- , "-- | doc for bar,"- , "-- second line."- , "bar = 2"- ]- sliceSymbol "bar" source- `shouldBe` Just "-- | doc for bar,\n-- second line.\nbar = 2"+compactDeclarations :: TestTree+compactDeclarations =+ testGroup+ "adjacent declarations without blank lines"+ [ testCase "does not swallow the following binding" do+ sliceSymbol "bar" (src ["foo = 1", "bar = 2", "baz = 3"])+ @?= Just "bar = 2"+ , testCase "does not swallow the preceding binding and its signature" do+ let source =+ src+ [ "foo :: Int"+ , "foo = 1"+ , "bar :: Int"+ , "bar = 2"+ ]+ sliceSymbol "bar" source @?= Just "bar :: Int\nbar = 2"+ , testCase "keeps the doc block but not a preceding declaration" do+ let source =+ src+ [ "foo = 1"+ , "-- | doc for bar"+ , "bar = 2"+ ]+ sliceSymbol "bar" source @?= Just "-- | doc for bar\nbar = 2"+ , testCase "keeps a multi-line doc block but not a preceding declaration" do+ let source =+ src+ [ "foo = 1"+ , "-- | doc for bar,"+ , "-- second line."+ , "bar = 2"+ ]+ sliceSymbol "bar" source+ @?= Just "-- | doc for bar,\n-- second line.\nbar = 2"+ ] -typeDeclarations :: Spec-typeDeclarations = describe "type declarations" do- it "slices a data declaration with doc and deriving clause" do- let source =- src- [ "-- | A JSON value."- , "data Value = Null | Bool Bool"- , " deriving (Show)"- , ""- , "instance Eq Value"- ]- sliceSymbol "Value" source- `shouldBe` Just "-- | A JSON value.\ndata Value = Null | Bool Bool\n deriving (Show)"-- it "keeps a multi-line block doc comment on a data declaration" do- let source =- src- [ "{- | A JSON value,"- , " as parsed. -}"- , "data Value = Null | Bool Bool"- , ""- ]- sliceSymbol "Value" source- `shouldBe` Just "{- | A JSON value,\n as parsed. -}\ndata Value = Null | Bool Bool"-- it "slices a newtype" do- sliceSymbol "Age" (src ["newtype Age = Age Int", ""])- `shouldBe` Just "newtype Age = Age Int"-- it "slices a type alias" do- sliceSymbol "Name" (src ["type Name = Text"])- `shouldBe` Just "type Name = Text"-- it "slices a type family" do- sliceSymbol "Elem" (src ["type family Elem c"])- `shouldBe` Just "type family Elem c"-- it "slices a class with its methods" do- let source =- src- [ "class Eq a => Container a where"- , " empty :: a"- , ""- , "foo = ()"- ]- sliceSymbol "Container" source- `shouldBe` Just "class Eq a => Container a where\n empty :: a"-- it "slices a record declaration including all fields" do- let source =- src- [ "data Person = Person"- , " { name :: Text"- , " , age :: Int"- , " }"- , " deriving (Show)"- , ""- ]- sliceSymbol "Person" source- `shouldBe` Just- ( "data Person = Person\n"- <> " { name :: Text\n"- <> " , age :: Int\n"- <> " }\n"- <> " deriving (Show)"- )-- it "slices a GADT declaration" do- let source =- src- [ "data Expr a where"- , " Lit :: Int -> Expr Int"- , " Add :: Expr Int -> Expr Int -> Expr Int"- , ""- ]- sliceSymbol "Expr" source- `shouldBe` Just- ( "data Expr a where\n"- <> " Lit :: Int -> Expr Int\n"- <> " Add :: Expr Int -> Expr Int -> Expr Int"- )+typeDeclarations :: TestTree+typeDeclarations =+ testGroup+ "type declarations"+ [ testCase "slices a data declaration with doc and deriving clause" do+ let source =+ src+ [ "-- | A JSON value."+ , "data Value = Null | Bool Bool"+ , " deriving (Show)"+ , ""+ , "instance Eq Value"+ ]+ sliceSymbol "Value" source+ @?= Just "-- | A JSON value.\ndata Value = Null | Bool Bool\n deriving (Show)"+ , testCase "keeps a multi-line block doc comment on a data declaration" do+ let source =+ src+ [ "{- | A JSON value,"+ , " as parsed. -}"+ , "data Value = Null | Bool Bool"+ , ""+ ]+ sliceSymbol "Value" source+ @?= Just "{- | A JSON value,\n as parsed. -}\ndata Value = Null | Bool Bool"+ , testCase "slices a newtype" do+ sliceSymbol "Age" (src ["newtype Age = Age Int", ""])+ @?= Just "newtype Age = Age Int"+ , testCase "slices a type alias" do+ sliceSymbol "Name" (src ["type Name = Text"])+ @?= Just "type Name = Text"+ , testCase "slices a type family" do+ sliceSymbol "Elem" (src ["type family Elem c"])+ @?= Just "type family Elem c"+ , testCase "slices a class with its methods" do+ let source =+ src+ [ "class Eq a => Container a where"+ , " empty :: a"+ , ""+ , "foo = ()"+ ]+ sliceSymbol "Container" source+ @?= Just "class Eq a => Container a where\n empty :: a"+ , testCase "slices a record declaration including all fields" do+ let source =+ src+ [ "data Person = Person"+ , " { name :: Text"+ , " , age :: Int"+ , " }"+ , " deriving (Show)"+ , ""+ ]+ sliceSymbol "Person" source+ @?= Just+ ( "data Person = Person\n"+ <> " { name :: Text\n"+ <> " , age :: Int\n"+ <> " }\n"+ <> " deriving (Show)"+ )+ , testCase "slices a GADT declaration" do+ let source =+ src+ [ "data Expr a where"+ , " Lit :: Int -> Expr Int"+ , " Add :: Expr Int -> Expr Int -> Expr Int"+ , ""+ ]+ sliceSymbol "Expr" source+ @?= Just+ ( "data Expr a where\n"+ <> " Lit :: Int -> Expr Int\n"+ <> " Add :: Expr Int -> Expr Int -> Expr Int"+ )+ ] -constructors :: Spec-constructors = describe "constructor queries" do- it "returns the enclosing data block for a constructor" do- let source =- src- [ "-- | Optionality."- , "data Maybe a = Nothing | Just a"- , ""- , "foo = ()"- ]- sliceSymbol "Just" source- `shouldBe` Just "-- | Optionality.\ndata Maybe a = Nothing | Just a"-- it "keeps a multi-line doc block above the enclosing data block" do- let source =- src- [ "-- | Optionality,"- , "-- the Maybe type."- , "data Maybe a = Nothing | Just a"- , ""- ]- sliceSymbol "Just" source- `shouldBe` Just "-- | Optionality,\n-- the Maybe type.\ndata Maybe a = Nothing | Just a"-- it "returns the enclosing GADT block for a GADT constructor" do- let source =- src- [ "data Expr a where"- , " Lit :: Int -> Expr Int"- , " Add :: Expr Int -> Expr Int -> Expr Int"- , ""- ]- sliceSymbol "Lit" source- `shouldBe` Just- ( "data Expr a where\n"- <> " Lit :: Int -> Expr Int\n"- <> " Add :: Expr Int -> Expr Int -> Expr Int"- )+constructors :: TestTree+constructors =+ testGroup+ "constructor queries"+ [ testCase "returns the enclosing data block for a constructor" do+ let source =+ src+ [ "-- | Optionality."+ , "data Maybe a = Nothing | Just a"+ , ""+ , "foo = ()"+ ]+ sliceSymbol "Just" source+ @?= Just "-- | Optionality.\ndata Maybe a = Nothing | Just a"+ , testCase "keeps a multi-line doc block above the enclosing data block" do+ let source =+ src+ [ "-- | Optionality,"+ , "-- the Maybe type."+ , "data Maybe a = Nothing | Just a"+ , ""+ ]+ sliceSymbol "Just" source+ @?= Just "-- | Optionality,\n-- the Maybe type.\ndata Maybe a = Nothing | Just a"+ , testCase "returns the enclosing GADT block for a GADT constructor" do+ let source =+ src+ [ "data Expr a where"+ , " Lit :: Int -> Expr Int"+ , " Add :: Expr Int -> Expr Int -> Expr Int"+ , ""+ ]+ sliceSymbol "Lit" source+ @?= Just+ ( "data Expr a where\n"+ <> " Lit :: Int -> Expr Int\n"+ <> " Add :: Expr Int -> Expr Int -> Expr Int"+ )+ ] -robustness :: Spec-robustness = describe "robustness" do- it "returns Nothing for a missing symbol" do- sliceSymbol "nope" (src ["foo = 1", "bar = 2"]) `shouldBe` Nothing-- it "returns Nothing for an empty query" do- sliceSymbol "" (src ["foo = 1"]) `shouldBe` Nothing-- it "does not choke on CPP-laden source" do- let source =- src- [ "#if MIN_VERSION_base(4,18,0)"- , "answer :: Int"- , "#else"- , "answer :: Integer"- , "#endif"- , "answer = 42"- ]- let result = sliceSymbol "answer" source- result `shouldSatisfy` isJust- fmap (T.isInfixOf "answer = 42") result `shouldBe` Just True+robustness :: TestTree+robustness =+ testGroup+ "robustness"+ [ testCase "returns Nothing for a missing symbol" do+ sliceSymbol "nope" (src ["foo = 1", "bar = 2"]) @?= Nothing+ , testCase "returns Nothing for an empty query" do+ sliceSymbol "" (src ["foo = 1"]) @?= Nothing+ , testCase "does not choke on CPP-laden source" do+ let source =+ src+ [ "#if MIN_VERSION_base(4,18,0)"+ , "answer :: Int"+ , "#else"+ , "answer :: Integer"+ , "#endif"+ , "answer = 42"+ ]+ let result = sliceSymbol "answer" source+ assertBool "expected Just" $ isJust (result)+ fmap (T.isInfixOf "answer = 42") result @?= Just True+ ] -- | The slice must span exactly the queried declaration: not truncating it -- early, not swallowing a neighbour, and not anchoring on the wrong entity. -- These are the over-/under-capture shapes real Hackage source triggers.-capturePrecision :: Spec-capturePrecision = describe "capture precision" do- it "keeps a where-clause that contains a blank line" do- let source =- src- [ "foo x = go x"- , " where"- , " go y = y + 1"- , ""- , " helper = 2"- , ""- , "bar = 3"- ]- sliceSymbol "foo" source- `shouldBe` Just "foo x = go x\n where\n go y = y + 1\n\n helper = 2"-- it "does not swallow a following binding that merely uses the operator" do- let source =- src- [ "(<+>) :: Int -> Int -> Int"- , "a <+> b = a + b"- , "merge x y = x <+> y"- ]- sliceSymbol "<+>" source- `shouldBe` Just "(<+>) :: Int -> Int -> Int\na <+> b = a + b"-- it "does not anchor on a superclass name in a class head" do- let source =- src- [ "class Eq a => Ord a where"- , " compare :: a -> a -> Ordering"- ]- sliceSymbol "Eq" source `shouldBe` Nothing-- it "slices a class that has a superclass context by its own name" do- let source =- src- [ "class Eq a => Ord a where"- , " compare :: a -> a -> Ordering"- ]- sliceSymbol "Ord" source- `shouldBe` Just "class Eq a => Ord a where\n compare :: a -> a -> Ordering"-- it "picks the data block that actually defines the constructor" do- let source =- src- [ "-- | Uses Just internally."- , "data Wrapper = Wrap Int"- , ""- , "data Maybe a = Nothing | Just a"- ]- sliceSymbol "Just" source- `shouldBe` Just "data Maybe a = Nothing | Just a"-- it "does not anchor on a constructor name used as a field type elsewhere" do- let source =- src- [ "data Holder = Holder Bar"- , ""- , "data Thing = Bar | Baz"- ]- sliceSymbol "Bar" source `shouldBe` Just "data Thing = Bar | Baz"-- it "keeps a multi-line {- | -} block doc comment" do- let source =- src- [ "{- | This does X"- , " over multiple lines. -}"- , "foo :: Int"- , "foo = 1"- ]- sliceSymbol "foo" source- `shouldBe` Just "{- | This does X\n over multiple lines. -}\nfoo :: Int\nfoo = 1"+capturePrecision :: TestTree+capturePrecision =+ testGroup+ "capture precision"+ [ testCase "keeps a where-clause that contains a blank line" do+ let source =+ src+ [ "foo x = go x"+ , " where"+ , " go y = y + 1"+ , ""+ , " helper = 2"+ , ""+ , "bar = 3"+ ]+ sliceSymbol "foo" source+ @?= Just "foo x = go x\n where\n go y = y + 1\n\n helper = 2"+ , testCase "does not swallow a following binding that merely uses the operator" do+ let source =+ src+ [ "(<+>) :: Int -> Int -> Int"+ , "a <+> b = a + b"+ , "merge x y = x <+> y"+ ]+ sliceSymbol "<+>" source+ @?= Just "(<+>) :: Int -> Int -> Int\na <+> b = a + b"+ , testCase "does not anchor on a superclass name in a class head" do+ let source =+ src+ [ "class Eq a => Ord a where"+ , " compare :: a -> a -> Ordering"+ ]+ sliceSymbol "Eq" source @?= Nothing+ , testCase "slices a class that has a superclass context by its own name" do+ let source =+ src+ [ "class Eq a => Ord a where"+ , " compare :: a -> a -> Ordering"+ ]+ sliceSymbol "Ord" source+ @?= Just "class Eq a => Ord a where\n compare :: a -> a -> Ordering"+ , testCase "picks the data block that actually defines the constructor" do+ let source =+ src+ [ "-- | Uses Just internally."+ , "data Wrapper = Wrap Int"+ , ""+ , "data Maybe a = Nothing | Just a"+ ]+ sliceSymbol "Just" source+ @?= Just "data Maybe a = Nothing | Just a"+ , testCase "does not anchor on a constructor name used as a field type elsewhere" do+ let source =+ src+ [ "data Holder = Holder Bar"+ , ""+ , "data Thing = Bar | Baz"+ ]+ sliceSymbol "Bar" source @?= Just "data Thing = Bar | Baz"+ , testCase "keeps a multi-line {- | -} block doc comment" do+ let source =+ src+ [ "{- | This does X"+ , " over multiple lines. -}"+ , "foo :: Int"+ , "foo = 1"+ ]+ sliceSymbol "foo" source+ @?= Just "{- | This does X\n over multiple lines. -}\nfoo :: Int\nfoo = 1"+ ]
test/Unit/Tricorder/SourceLookup/TarballSpec.hs view
@@ -1,7 +1,8 @@-module Unit.Tricorder.SourceLookup.TarballSpec (spec_Tarball) where+module Unit.Tricorder.SourceLookup.TarballSpec (test_Tarball) where import System.FilePath (isAbsolute, (</>))-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Codec.Archive.Tar qualified as Tar import Codec.Archive.Tar.Entry qualified as Tar@@ -17,68 +18,65 @@ ) -spec_Tarball :: Spec-spec_Tarball = do- describe "splitPackageId" do- it "splits a simple package id" do- splitPackageId "aeson-2.2.5.0" `shouldBe` ("aeson", "2.2.5.0")-- it "keeps hyphens inside the package name" do- splitPackageId "list-t-1.0.5.7" `shouldBe` ("list-t", "1.0.5.7")-- describe "tarballPath" do- it "derives the cache path for a package id" do- tarballPath "/c" "hackage.haskell.org" "aeson-2.2.5.0"- `shouldBe` "/c" </> "hackage.haskell.org/aeson/2.2.5.0/aeson-2.2.5.0.tar.gz"-- describe "cabalPackagesDirs" do- it "honors CABAL_DIR" do- cabalPackagesDirs [("CABAL_DIR", "/cd")] `shouldBe` ["/cd/packages"]-- it "includes the legacy ~/.cabal location alongside the XDG one" do- cabalPackagesDirs [("HOME", "/h")]- `shouldBe` ["/h/.cache/cabal/packages", "/h/.cabal/packages"]-- it "yields no candidates (never a relative path) when HOME is unset" do- cabalPackagesDirs [] `shouldBe` []-- it "only ever produces absolute candidates" do- all isAbsolute (cabalPackagesDirs [("HOME", "/h"), ("XDG_CACHE_HOME", "/x")])- `shouldBe` True-- describe "matchesModule" do- it "matches a src/ layout entry" do- matchesModule "Data.Aeson" "aeson-2.2.5.0/src/Data/Aeson.hs" `shouldBe` True-- it "matches a lib/ layout entry" do- matchesModule "Data.Aeson" "aeson-2.2.5.0/lib/Data/Aeson.hs" `shouldBe` True-- it "matches a flat layout entry" do- matchesModule "Data.Aeson" "aeson-2.2.5.0/Data/Aeson.hs" `shouldBe` True-- it "does not match a different module under the same prefix" do- matchesModule "Data.Aeson" "aeson-2.2.5.0/src/Data/Aeson/Types.hs" `shouldBe` False-- it "does not match a deeper module whose final component coincides" do- -- Module `Lens` must not resolve to the file for `Control.Lens`.- matchesModule "Lens" "pkg-1.0/Control/Lens.hs" `shouldBe` False-- it "matches a preprocessed .hsc entry" do- matchesModule "System.Posix.Files" "unix-2.8.5.0/System/Posix/Files.hsc" `shouldBe` True-- it "matches a literate .lhs entry" do- matchesModule "Data.Ratio" "base-4.19.0.0/src/Data/Ratio.lhs" `shouldBe` True-- describe "extractModule" do- it "extracts a member by module name" do- extractModule "Data.Aeson" fixtureTarball `shouldBe` Just "module Data.Aeson where\n"-- it "returns Nothing when the module is absent" do- extractModule "Data.Missing" fixtureTarball `shouldBe` Nothing-- it "prefers a library source path over a same-named test path" do- extractModule "Data.Aeson" dupModuleTarball- `shouldBe` Just "module Data.Aeson (lib) where\n"+test_Tarball :: TestTree+test_Tarball =+ testGroup+ "Tarball"+ [ testGroup+ "splitPackageId"+ [ testCase "splits a simple package id" do+ splitPackageId "aeson-2.2.5.0" @?= ("aeson", "2.2.5.0")+ , testCase "keeps hyphens inside the package name" do+ splitPackageId "list-t-1.0.5.7" @?= ("list-t", "1.0.5.7")+ ]+ , testGroup+ "tarballPath"+ [ testCase "derives the cache path for a package id" do+ tarballPath "/c" "hackage.haskell.org" "aeson-2.2.5.0"+ @?= "/c" </> "hackage.haskell.org/aeson/2.2.5.0/aeson-2.2.5.0.tar.gz"+ ]+ , testGroup+ "cabalPackagesDirs"+ [ testCase "honors CABAL_DIR" do+ cabalPackagesDirs [("CABAL_DIR", "/cd")] @?= ["/cd/packages"]+ , testCase "includes the legacy ~/.cabal location alongside the XDG one" do+ cabalPackagesDirs [("HOME", "/h")]+ @?= ["/h/.cache/cabal/packages", "/h/.cabal/packages"]+ , testCase "yields no candidates (never a relative path) when HOME is unset" do+ cabalPackagesDirs [] @?= []+ , testCase "only ever produces absolute candidates" do+ all isAbsolute (cabalPackagesDirs [("HOME", "/h"), ("XDG_CACHE_HOME", "/x")])+ @?= True+ ]+ , testGroup+ "matchesModule"+ [ testCase "matches a src/ layout entry" do+ matchesModule "Data.Aeson" "aeson-2.2.5.0/src/Data/Aeson.hs" @?= True+ , testCase "matches a lib/ layout entry" do+ matchesModule "Data.Aeson" "aeson-2.2.5.0/lib/Data/Aeson.hs" @?= True+ , testCase "matches a flat layout entry" do+ matchesModule "Data.Aeson" "aeson-2.2.5.0/Data/Aeson.hs" @?= True+ , testCase "does not match a different module under the same prefix" do+ matchesModule "Data.Aeson" "aeson-2.2.5.0/src/Data/Aeson/Types.hs" @?= False+ , testCase "does not match a deeper module whose final component coincides" do+ -- Module `Lens` must not resolve to the file for `Control.Lens`.+ matchesModule "Lens" "pkg-1.0/Control/Lens.hs" @?= False+ , testCase "matches a preprocessed .hsc entry" do+ matchesModule "System.Posix.Files" "unix-2.8.5.0/System/Posix/Files.hsc" @?= True+ , testCase "matches a literate .lhs entry" do+ matchesModule "Data.Ratio" "base-4.19.0.0/src/Data/Ratio.lhs" @?= True+ ]+ , testGroup+ "extractModule"+ [ testCase "extracts a member by module name" do+ extractModule "Data.Aeson" fixtureTarball @?= Just "module Data.Aeson where\n"+ , testCase "returns Nothing when the module is absent" do+ extractModule "Data.Missing" fixtureTarball @?= Nothing+ , testCase "prefers a library source path over a same-named test path" do+ extractModule "Data.Aeson" dupModuleTarball+ @?= Just "module Data.Aeson (lib) where\n"+ ]+ ] -- | A gzipped tar with a single source member.
test/Unit/Tricorder/SourceLookupSpec.hs view
@@ -1,4 +1,4 @@-module Unit.Tricorder.SourceLookupSpec (spec_SourceLookup) where+module Unit.Tricorder.SourceLookupSpec (test_SourceLookup) where import Atelier.Effects.Cache (Cache, runCacheForever) import Atelier.Effects.Env (Env, runEnvConst)@@ -10,7 +10,8 @@ import Effectful.Dispatch.Dynamic (interpret_) import Effectful.State.Static.Shared (State, evalState, gets, modify) import System.FilePath ((</>))-import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery (..)) import Codec.Archive.Tar qualified as Tar@@ -22,7 +23,7 @@ import Data.Map.Strict qualified as Map import Data.Text qualified as T -import Tricorder.Session.Command (Repl (..))+import Tricorder.Session.Repl (Repl (..)) import Tricorder.SourceLookup ( ModuleSourceResult (..) , lookupModuleSource@@ -35,104 +36,101 @@ import Tricorder.SourceLookup.PackageStore qualified as PackageStore -spec_SourceLookup :: Spec-spec_SourceLookup = describe "lookupModuleSource" do- it "reads the whole module from a cached tarball" do- result <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch- $ lookupModuleSource (wholeModule "Data.Aeson")- result `shouldBe` SourceFound (wholeModule "Data.Aeson") moduleSource-- it "slices a symbol (with its doc block) from a cached tarball" do- result <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch- $ lookupModuleSource (symbol "Data.Aeson" "encode")- result- `shouldBe` SourceFound- (symbol "Data.Aeson" "encode")- "-- | Encode a value as JSON.\nencode :: Value -> ByteString\nencode = undefined"-- it "returns FunctionNotFound for a symbol absent from the module" do- result <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch- $ lookupModuleSource (symbol "Data.Aeson" "nope")- result `shouldBe` FunctionNotFound (symbol "Data.Aeson" "nope")-- it "returns SourceNotFound when the module is in no package" do- result <-- runTest [NextFindModule Nothing] Map.empty noFetch- $ lookupModuleSource (wholeModule "Data.Unknown")- result `shouldBe` SourceNotFound (wholeModule "Data.Unknown")-- it "fetches from Hackage on a cache miss, then reads the now-fetched tarball" do- result <-- runTest- [NextFindModule (Just "aeson-2.2.5.0")]- Map.empty- (pure (Success (BSL.toStrict tarballBytes)))- $ lookupModuleSource (wholeModule "Data.Aeson")- result `shouldBe` SourceFound (wholeModule "Data.Aeson") moduleSource-- it "returns SourceUnavailable when the package is not found on Hackage" do- result <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty noFetch- $ lookupModuleSource (wholeModule "Data.Aeson")- result `shouldBe` SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"-- it "caches the result so a second lookup needs no further resolution" do- -- Only one NextFindModule is scripted; the second lookup must be served- -- entirely from cache (module→package and package→source).- (r1, r2) <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch $ do- r1 <- lookupModuleSource (wholeModule "Data.Aeson")- r2 <- lookupModuleSource (wholeModule "Data.Aeson")- pure (r1, r2)- r1 `shouldBe` SourceFound (wholeModule "Data.Aeson") moduleSource- r2 `shouldBe` SourceFound (wholeModule "Data.Aeson") moduleSource-- it "caches an unavailable result and does not re-fetch on a repeat lookup" do- -- The tarball is absent and every fetch reports the package as not- -- found, so the first lookup is SourceUnavailable. A repeat lookup must- -- be served from cache — no second Hackage fetch on the (network)- -- request path.- fetchCount <- IORef.newIORef (0 :: Int)- let countingFetch = do- liftIO (IORef.modifyIORef' fetchCount (+ 1))- noFetch- (r1, r2) <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty countingFetch $ do- r1 <- lookupModuleSource (wholeModule "Data.Aeson")- r2 <- lookupModuleSource (wholeModule "Data.Aeson")- pure (r1, r2)- r1 `shouldBe` SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"- r2 `shouldBe` SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"- fetches <- IORef.readIORef fetchCount- fetches `shouldBe` 1-- it "re-fetches after a failed fetch rather than caching the failure" do- -- A failed Hackage fetch (offline, DNS failure, 5xx) is transient, so- -- the resulting SourceUnavailable must NOT be cached: a repeat lookup- -- has to retry the fetch, or a brief network blip pins unavailability- -- for the whole cache window.- fetchCount <- IORef.newIORef (0 :: Int)- let failingFetch = do- liftIO (IORef.modifyIORef' fetchCount (+ 1))- pure (Failure "network unreachable")- (r1, r2) <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty failingFetch $ do- r1 <- lookupModuleSource (wholeModule "Data.Aeson")- r2 <- lookupModuleSource (wholeModule "Data.Aeson")- pure (r1, r2)- r1 `shouldBe` SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"- r2 `shouldBe` SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"- fetches <- IORef.readIORef fetchCount- fetches `shouldBe` 2-- it "finds a tarball in the legacy ~/.cabal cache location" do- result <-- runTest [NextFindModule (Just "aeson-2.2.5.0")] withLegacyTarball noFetch- $ lookupModuleSource (wholeModule "Data.Aeson")- result `shouldBe` SourceFound (wholeModule "Data.Aeson") moduleSource+test_SourceLookup :: TestTree+test_SourceLookup =+ testGroup+ "SourceLookup"+ [ testGroup+ "lookupModuleSource"+ [ testCase "reads the whole module from a cached tarball" do+ result <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch+ $ lookupModuleSource (wholeModule "Data.Aeson")+ result @?= SourceFound (wholeModule "Data.Aeson") moduleSource+ , testCase "slices a symbol (with its doc block) from a cached tarball" do+ result <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch+ $ lookupModuleSource (symbol "Data.Aeson" "encode")+ result+ @?= SourceFound+ (symbol "Data.Aeson" "encode")+ "-- | Encode a value as JSON.\nencode :: Value -> ByteString\nencode = undefined"+ , testCase "returns FunctionNotFound for a symbol absent from the module" do+ result <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch+ $ lookupModuleSource (symbol "Data.Aeson" "nope")+ result @?= FunctionNotFound (symbol "Data.Aeson" "nope")+ , testCase "returns SourceNotFound when the module is in no package" do+ result <-+ runTest [NextFindModule Nothing] Map.empty noFetch+ $ lookupModuleSource (wholeModule "Data.Unknown")+ result @?= SourceNotFound (wholeModule "Data.Unknown")+ , testCase "fetches from Hackage on a cache miss, then reads the now-fetched tarball" do+ result <-+ runTest+ [NextFindModule (Just "aeson-2.2.5.0")]+ Map.empty+ (pure (Success (BSL.toStrict tarballBytes)))+ $ lookupModuleSource (wholeModule "Data.Aeson")+ result @?= SourceFound (wholeModule "Data.Aeson") moduleSource+ , testCase "returns SourceUnavailable when the package is not found on Hackage" do+ result <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty noFetch+ $ lookupModuleSource (wholeModule "Data.Aeson")+ result @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"+ , testCase "caches the result so a second lookup needs no further resolution" do+ -- Only one NextFindModule is scripted; the second lookup must be served+ -- entirely from cache (module→package and package→source).+ (r1, r2) <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch $ do+ r1 <- lookupModuleSource (wholeModule "Data.Aeson")+ r2 <- lookupModuleSource (wholeModule "Data.Aeson")+ pure (r1, r2)+ r1 @?= SourceFound (wholeModule "Data.Aeson") moduleSource+ r2 @?= SourceFound (wholeModule "Data.Aeson") moduleSource+ , testCase "caches an unavailable result and does not re-fetch on a repeat lookup" do+ -- The tarball is absent and every fetch reports the package as not+ -- found, so the first lookup is SourceUnavailable. A repeat lookup must+ -- be served from cache — no second Hackage fetch on the (network)+ -- request path.+ fetchCount <- IORef.newIORef (0 :: Int)+ let countingFetch = do+ liftIO (IORef.modifyIORef' fetchCount (+ 1))+ noFetch+ (r1, r2) <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty countingFetch $ do+ r1 <- lookupModuleSource (wholeModule "Data.Aeson")+ r2 <- lookupModuleSource (wholeModule "Data.Aeson")+ pure (r1, r2)+ r1 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"+ r2 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"+ fetches <- IORef.readIORef fetchCount+ fetches @?= 1+ , testCase "re-fetches after a failed fetch rather than caching the failure" do+ -- A failed Hackage fetch (offline, DNS failure, 5xx) is transient, so+ -- the resulting SourceUnavailable must NOT be cached: a repeat lookup+ -- has to retry the fetch, or a brief network blip pins unavailability+ -- for the whole cache window.+ fetchCount <- IORef.newIORef (0 :: Int)+ let failingFetch = do+ liftIO (IORef.modifyIORef' fetchCount (+ 1))+ pure (Failure "network unreachable")+ (r1, r2) <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty failingFetch $ do+ r1 <- lookupModuleSource (wholeModule "Data.Aeson")+ r2 <- lookupModuleSource (wholeModule "Data.Aeson")+ pure (r1, r2)+ r1 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"+ r2 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"+ fetches <- IORef.readIORef fetchCount+ fetches @?= 2+ , testCase "finds a tarball in the legacy ~/.cabal cache location" do+ result <-+ runTest [NextFindModule (Just "aeson-2.2.5.0")] withLegacyTarball noFetch+ $ lookupModuleSource (wholeModule "Data.Aeson")+ result @?= SourceFound (wholeModule "Data.Aeson") moduleSource+ ]+ ] --------------------------------------------------------------------------------
test/Unit/Tricorder/TestOutputSpec.hs view
@@ -1,6 +1,7 @@-module Unit.Tricorder.TestOutputSpec (spec_TestOutput) where+module Unit.Tricorder.TestOutputSpec (test_TestOutput) where -import Test.Hspec+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=)) import Tricorder.Build.Duration (Duration (..)) import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput, stripGhciNoise)@@ -8,182 +9,168 @@ import Tricorder.Build.Test qualified as Test -spec_TestOutput :: Spec-spec_TestOutput = do- describe "parseHspecOutput" do- it "returns empty list for empty output" do- parseHspecOutput "" `shouldBe` []-- it "parses a passing test" do- let output = " foo\n bar baz: OK\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "bar baz:", outcome = Test.Passed}]-- it "parses a failing test" do- let output = " foo\n bar baz: FAIL\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "bar baz:", outcome = Test.Failed ""}]-- it "captures failure details" do- let output =- " a test: FAIL\n"- <> " expected: 1\n"- <> " but got: 2\n"- <> " another test: OK\n"- parseHspecOutput output- `shouldBe` [ Test.Case- { description = "a test:"- , outcome = Test.Failed "expected: 1\nbut got: 2"- }- , Test.Case {description = "another test:", outcome = Test.Passed}- ]-- it "stops collecting details when indentation returns to test level" do- let output =- " failing: FAIL\n"- <> " detail line\n"- <> " passing: OK\n"- let cases = parseHspecOutput output- length cases `shouldBe` 2- case cases of- (c : _) -> c.outcome `shouldBe` Test.Failed "detail line"- [] -> expectationFailure "expected at least one test case"-- it "skips group header lines" do- let output =- " MyModule\n"- <> " someFunction\n"- <> " does the thing: OK\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "does the thing:", outcome = Test.Passed}]-- it "parses mixed passing and failing tests" do- let output =- " Suite\n"- <> " passes: OK\n"- <> " fails: FAIL\n"- <> " reason\n"- <> " also passes: OK\n"- parseHspecOutput output- `shouldBe` [ Test.Case {description = "passes:", outcome = Test.Passed}- , Test.Case {description = "fails:", outcome = Test.Failed "reason"}- , Test.Case {description = "also passes:", outcome = Test.Passed}- ]-- it "parses a passing test with a timing annotation" do- let output = " slow test: OK (0.05s)\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "slow test:", outcome = Test.Passed}]-- it "parses a passing test with a millisecond annotation" do- let output = " fast property: OK (12ms)\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "fast property:", outcome = Test.Passed}]-- it "parses a failing test with a timing annotation" do- let output = " slow fail: FAIL (0.03s)\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "slow fail:", outcome = Test.Failed ""}]-- it "does not strip a non-timing parenthetical in the description" do- let output = " test (corner case): OK\n"- parseHspecOutput output- `shouldBe` [Test.Case {description = "test (corner case):", outcome = Test.Passed}]-- describe "parseHspecDuration" do- it "returns Nothing for empty output" do- parseHspecDuration "" `shouldBe` Nothing-- it "returns Nothing when no timing line is present" do- parseHspecDuration "2 examples, 0 failures\n" `shouldBe` Nothing-- it "parses duration from passing summary line" do- parseHspecDuration "All 177 tests passed (0.05s)\n"- `shouldBe` Just (Duration 50)-- it "parses duration from failing summary line" do- parseHspecDuration "1 out of 177 tests failed (0.06s)\n"- `shouldBe` Just (Duration 60)-- it "does not match indented individual test timing lines" do- parseHspecDuration " entry is evicted after cleanup thread fires past TTL: OK (0.05s)\n"- `shouldBe` Nothing-- it "parses duration embedded in full hspec output" do- let output =- " Suite\n"- <> " passes: OK\n"- <> " slow test: OK (0.05s)\n"- <> "\n"- <> "All 2 tests passed (0.5s)\n"- parseHspecDuration output `shouldBe` Just (Duration 500)-- describe "stripGhciNoise" do- it "passes through empty list" do- stripGhciNoise [] `shouldBe` []-- it "passes through output with no ghci prompt" do- let ls = ["line one", "line two", "line three"]- stripGhciNoise ls `shouldBe` ls-- it "strips cabal build preamble" do- let ls =- [ "Resolving dependencies..."- , "Build profile: -w ghc-9.6.3 -O1"- , "ghci> :reload"- , " test one: OK"- , " test two: OK"- ]- stripGhciNoise ls- `shouldBe` [ " test one: OK"- , " test two: OK"- ]-- it "strips trailing ghci prompt" do- let ls =- [ "ghci> :reload"- , " a test: OK"- , "ghci> "- ]- stripGhciNoise ls `shouldBe` [" a test: OK"]-- it "strips trailing \"Leaving GHCi.\" line" do- let ls =- [ "ghci> :reload"- , " a test: OK"- , "Leaving GHCi."- ]- stripGhciNoise ls `shouldBe` [" a test: OK"]-- it "strips trailing \"*** Exception: ...\" lines" do- let ls =- [ "ghci> :reload"- , " a test: OK"- , "*** Exception: ExitSuccess"- ]- stripGhciNoise ls `shouldBe` [" a test: OK"]-- it "full round-trip strips build noise, keeps test output" do- let ls =- [ "Resolving dependencies..."- , "Build profile: -w ghc-9.6.3 -O1"- , "Preprocessing test suite 'spec' for tricorder-0.1.0.0..."- , "ghci> :reload"- , " Suite"- , " passes: OK"- , " fails: FAIL"- , " some detail"- , ""- , "Finished in 0.0001 seconds"- , "ghci> "- , "Leaving GHCi."- , "*** Exception: ExitFailure 1"- ]- stripGhciNoise ls- `shouldBe` [ " Suite"- , " passes: OK"- , " fails: FAIL"- , " some detail"- , ""- , "Finished in 0.0001 seconds"- ]+test_TestOutput :: TestTree+test_TestOutput =+ testGroup+ "TestOutput"+ [ testGroup+ "parseHspecOutput"+ [ testCase "returns empty list for empty output" do+ parseHspecOutput "" @?= []+ , testCase "parses a passing test" do+ let output = " foo\n bar baz: OK\n"+ parseHspecOutput output+ @?= [Test.Case {description = "bar baz:", outcome = Test.Passed}]+ , testCase "parses a failing test" do+ let output = " foo\n bar baz: FAIL\n"+ parseHspecOutput output+ @?= [Test.Case {description = "bar baz:", outcome = Test.Failed ""}]+ , testCase "captures failure details" do+ let output =+ " a test: FAIL\n"+ <> " expected: 1\n"+ <> " but got: 2\n"+ <> " another test: OK\n"+ parseHspecOutput output+ @?= [ Test.Case+ { description = "a test:"+ , outcome = Test.Failed "expected: 1\nbut got: 2"+ }+ , Test.Case {description = "another test:", outcome = Test.Passed}+ ]+ , testCase "stops collecting details when indentation returns to test level" do+ let output =+ " failing: FAIL\n"+ <> " detail line\n"+ <> " passing: OK\n"+ let cases = parseHspecOutput output+ length cases @?= 2+ case cases of+ (c : _) -> c.outcome @?= Test.Failed "detail line"+ [] -> assertFailure "expected at least one test case"+ , testCase "skips group header lines" do+ let output =+ " MyModule\n"+ <> " someFunction\n"+ <> " does the thing: OK\n"+ parseHspecOutput output+ @?= [Test.Case {description = "does the thing:", outcome = Test.Passed}]+ , testCase "parses mixed passing and failing tests" do+ let output =+ " Suite\n"+ <> " passes: OK\n"+ <> " fails: FAIL\n"+ <> " reason\n"+ <> " also passes: OK\n"+ parseHspecOutput output+ @?= [ Test.Case {description = "passes:", outcome = Test.Passed}+ , Test.Case {description = "fails:", outcome = Test.Failed "reason"}+ , Test.Case {description = "also passes:", outcome = Test.Passed}+ ]+ , testCase "parses a passing test with a timing annotation" do+ let output = " slow test: OK (0.05s)\n"+ parseHspecOutput output+ @?= [Test.Case {description = "slow test:", outcome = Test.Passed}]+ , testCase "parses a passing test with a millisecond annotation" do+ let output = " fast property: OK (12ms)\n"+ parseHspecOutput output+ @?= [Test.Case {description = "fast property:", outcome = Test.Passed}]+ , testCase "parses a failing test with a timing annotation" do+ let output = " slow fail: FAIL (0.03s)\n"+ parseHspecOutput output+ @?= [Test.Case {description = "slow fail:", outcome = Test.Failed ""}]+ , testCase "does not strip a non-timing parenthetical in the description" do+ let output = " test (corner case): OK\n"+ parseHspecOutput output+ @?= [Test.Case {description = "test (corner case):", outcome = Test.Passed}]+ ]+ , testGroup+ "parseHspecDuration"+ [ testCase "returns Nothing for empty output" do+ parseHspecDuration "" @?= Nothing+ , testCase "returns Nothing when no timing line is present" do+ parseHspecDuration "2 examples, 0 failures\n" @?= Nothing+ , testCase "parses duration from passing summary line" do+ parseHspecDuration "All 177 tests passed (0.05s)\n"+ @?= Just (Duration 50)+ , testCase "parses duration from failing summary line" do+ parseHspecDuration "1 out of 177 tests failed (0.06s)\n"+ @?= Just (Duration 60)+ , testCase "does not match indented individual test timing lines" do+ parseHspecDuration " entry is evicted after cleanup thread fires past TTL: OK (0.05s)\n"+ @?= Nothing+ , testCase "parses duration embedded in full hspec output" do+ let output =+ " Suite\n"+ <> " passes: OK\n"+ <> " slow test: OK (0.05s)\n"+ <> "\n"+ <> "All 2 tests passed (0.5s)\n"+ parseHspecDuration output @?= Just (Duration 500)+ ]+ , testGroup+ "stripGhciNoise"+ [ testCase "passes through empty list" do+ stripGhciNoise [] @?= []+ , testCase "passes through output with no ghci prompt" do+ let ls = ["line one", "line two", "line three"]+ stripGhciNoise ls @?= ls+ , testCase "strips cabal build preamble" do+ let ls =+ [ "Resolving dependencies..."+ , "Build profile: -w ghc-9.6.3 -O1"+ , "ghci> :reload"+ , " test one: OK"+ , " test two: OK"+ ]+ stripGhciNoise ls+ @?= [ " test one: OK"+ , " test two: OK"+ ]+ , testCase "strips trailing ghci prompt" do+ let ls =+ [ "ghci> :reload"+ , " a test: OK"+ , "ghci> "+ ]+ stripGhciNoise ls @?= [" a test: OK"]+ , testCase "strips trailing \"Leaving GHCi.\" line" do+ let ls =+ [ "ghci> :reload"+ , " a test: OK"+ , "Leaving GHCi."+ ]+ stripGhciNoise ls @?= [" a test: OK"]+ , testCase "strips trailing \"*** Exception: ...\" lines" do+ let ls =+ [ "ghci> :reload"+ , " a test: OK"+ , "*** Exception: ExitSuccess"+ ]+ stripGhciNoise ls @?= [" a test: OK"]+ , testCase "full round-trip strips build noise, keeps test output" do+ let ls =+ [ "Resolving dependencies..."+ , "Build profile: -w ghc-9.6.3 -O1"+ , "Preprocessing test suite 'spec' for tricorder-0.1.0.0..."+ , "ghci> :reload"+ , " Suite"+ , " passes: OK"+ , " fails: FAIL"+ , " some detail"+ , ""+ , "Finished in 0.0001 seconds"+ , "ghci> "+ , "Leaving GHCi."+ , "*** Exception: ExitFailure 1"+ ]+ stripGhciNoise ls+ @?= [ " Suite"+ , " passes: OK"+ , " fails: FAIL"+ , " some detail"+ , ""+ , "Finished in 0.0001 seconds"+ ]+ ]+ ]
test/Unit/Tricorder/WaitersSpec.hs view
@@ -1,11 +1,13 @@-module Unit.Tricorder.WaitersSpec (spec_Waiters) where+module Unit.Tricorder.WaitersSpec (test_Waiters) where import Atelier.Effects.Conc (Conc, runConc)+import Control.Exception (try) import Effectful (IOE, runEff) import Effectful.Concurrent (Concurrent, runConcurrent) import Effectful.Exception (catch, throwIO) import Effectful.State.Static.Shared (State, get, modify, runState)-import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList, shouldThrow)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=)) import Atelier.Effects.Conc qualified as Conc import Atelier.Types.Semaphore qualified as Sem@@ -37,104 +39,110 @@ logEvent name = modify (<> [name]) -spec_Waiters :: Spec-spec_Waiters = do- describe "Waiters.without" do- describe "with waiters" $ it "skips the action" do- (_, events) <- runWaitersTest do- proceed <- Sem.new- started <- Sem.new- waiter <- Conc.fork $ Waiters.with do- void $ Sem.set started- Sem.wait proceed- logEvent "waiter"- Sem.wait started- -- Called directly (not forked): Without never blocks, so- -- forking it here would race its no-waiters check against- -- the `Sem.set proceed` below, making the test flaky.- Waiters.without (logEvent "without")- void $ Sem.set proceed- Conc.await waiter- events `shouldBe` ["waiter"]- it "runs its action immediately when there are no active waiters" do- (_, events) <- runWaitersTest $ Waiters.without (logEvent "ran")- events `shouldBe` ["ran"]-- describe "Waiters.with" do- it "runs its action" do- (_, events) <- runWaitersTest $ Waiters.with (logEvent "ran")- events `shouldBe` ["ran"]-- it "lets a subsequent Waiters.wait proceed once it has completed" do- (_, events) <- runWaitersTest do- Waiters.with (logEvent "waiter")- Waiters.wait (logEvent "quiesced")- events `shouldBe` ["waiter", "quiesced"]-- it "blocks a concurrent Waiters.wait until its action finishes" do- (_, events) <- runWaitersTest do- started <- Sem.new- proceed <- Sem.new- waiter <- Conc.fork $ Waiters.with do- _ <- Sem.set started- Sem.wait proceed- logEvent "waiter-end"- Sem.wait started- quiescent <- Conc.fork $ Waiters.wait (logEvent "quiesced")- _ <- Sem.set proceed- Conc.await waiter- Conc.await quiescent- -- Blocking is guaranteed by the STM retry on the waiter count,- -- not by scheduling luck, so this order holds on every run.- events `shouldBe` ["waiter-end", "quiesced"]-- it "blocks wait until every concurrent waiter finishes" do- (afterFirst, events) <- runWaitersTest do- started1 <- Sem.new- proceed1 <- Sem.new- waiter1 <- Conc.fork $ Waiters.with do- _ <- Sem.set started1- Sem.wait proceed1- logEvent "waiter1-end"- Sem.wait started1-- quiescent <- Conc.fork $ Waiters.wait (logEvent "quiesced")-- started2 <- Sem.new- proceed2 <- Sem.new- waiter2 <- Conc.fork $ Waiters.with do- _ <- Sem.set started2- Sem.wait proceed2- logEvent "waiter2-end"- Sem.wait started2+test_Waiters :: TestTree+test_Waiters =+ testGroup+ "Waiters"+ [ testGroup+ "Waiters.without"+ [ testGroup+ "with waiters"+ [ testCase "skips the action" do+ (_, events) <- runWaitersTest do+ proceed <- Sem.new+ started <- Sem.new+ waiter <- Conc.fork $ Waiters.with do+ void $ Sem.set started+ Sem.wait proceed+ logEvent "waiter"+ Sem.wait started+ -- Called directly (not forked): Without never blocks, so+ -- forking it here would race its no-waiters check against+ -- the `Sem.set proceed` below, making the test flaky.+ Waiters.without (logEvent "without")+ void $ Sem.set proceed+ Conc.await waiter+ events @?= ["waiter"]+ ]+ , testCase "runs its action immediately when there are no active waiters" do+ (_, events) <- runWaitersTest $ Waiters.without (logEvent "ran")+ events @?= ["ran"]+ ]+ , testGroup+ "Waiters.with"+ [ testCase "runs its action" do+ (_, events) <- runWaitersTest $ Waiters.with (logEvent "ran")+ events @?= ["ran"]+ , testCase "lets a subsequent Waiters.wait proceed once it has completed" do+ (_, events) <- runWaitersTest do+ Waiters.with (logEvent "waiter")+ Waiters.wait (logEvent "quiesced")+ events @?= ["waiter", "quiesced"]+ , testCase "blocks a concurrent Waiters.wait until its action finishes" do+ (_, events) <- runWaitersTest do+ started <- Sem.new+ proceed <- Sem.new+ waiter <- Conc.fork $ Waiters.with do+ _ <- Sem.set started+ Sem.wait proceed+ logEvent "waiter-end"+ Sem.wait started+ quiescent <- Conc.fork $ Waiters.wait (logEvent "quiesced")+ _ <- Sem.set proceed+ Conc.await waiter+ Conc.await quiescent+ -- Blocking is guaranteed by the STM retry on the waiter count,+ -- not by scheduling luck, so this order holds on every run.+ events @?= ["waiter-end", "quiesced"]+ , testCase "blocks wait until every concurrent waiter finishes" do+ (afterFirst, events) <- runWaitersTest do+ started1 <- Sem.new+ proceed1 <- Sem.new+ waiter1 <- Conc.fork $ Waiters.with do+ _ <- Sem.set started1+ Sem.wait proceed1+ logEvent "waiter1-end"+ Sem.wait started1 - _ <- Sem.set proceed1- Conc.await waiter1- -- waiter2 is still active here, so the waiter count cannot- -- have reached zero yet: quiescent is still blocked, not- -- just "hasn't been scheduled".- afterFirst <- get- _ <- Sem.set proceed2- Conc.await waiter2- -- No snapshot is taken here: once waiter2's slot is released,- -- quiescent's STM retry can wake and log concurrently with- -- this thread, so there is no deterministic in-between state- -- to observe. Only the final, fully-awaited state is safe to- -- assert on.- Conc.await quiescent- pure afterFirst- afterFirst `shouldMatchList` ["waiter1-end"]- events `shouldMatchList` ["waiter1-end", "waiter2-end", "quiesced"]+ quiescent <- Conc.fork $ Waiters.wait (logEvent "quiesced") - describe "exception safety" do- it "propagates an exception raised by the waiter's action" do- let action = runWaitersTest $ Waiters.with (throwIO $ TestException "boom")- action `shouldThrow` \(TestException msg) -> msg == "boom"+ started2 <- Sem.new+ proceed2 <- Sem.new+ waiter2 <- Conc.fork $ Waiters.with do+ _ <- Sem.set started2+ Sem.wait proceed2+ logEvent "waiter2-end"+ Sem.wait started2 - it "releases the waiter slot even when the action throws" do- (_, events) <- runWaitersTest do- _ <-- Waiters.with (throwIO $ TestException "boom")- `catch` \(_ :: TestException) -> pure ()- Waiters.without (logEvent "quiesced")- events `shouldBe` ["quiesced"]+ _ <- Sem.set proceed1+ Conc.await waiter1+ -- waiter2 is still active here, so the waiter count cannot+ -- have reached zero yet: quiescent is still blocked, not+ -- just "hasn't been scheduled".+ afterFirst <- get+ _ <- Sem.set proceed2+ Conc.await waiter2+ -- No snapshot is taken here: once waiter2's slot is released,+ -- quiescent's STM retry can wake and log concurrently with+ -- this thread, so there is no deterministic in-between state+ -- to observe. Only the final, fully-awaited state is safe to+ -- assert on.+ Conc.await quiescent+ pure afterFirst+ afterFirst @?= ["waiter1-end"]+ events @?= ["waiter1-end", "waiter2-end", "quiesced"]+ ]+ , testGroup+ "exception safety"+ [ testCase "propagates an exception raised by the waiter's action" do+ res <- try $ runWaitersTest $ Waiters.with (throwIO $ TestException "boom")+ res @?= (Left (TestException "boom") :: Either TestException ((), [Text]))+ , testCase "releases the waiter slot even when the action throws" do+ (_, events) <- runWaitersTest do+ _ <-+ Waiters.with (throwIO $ TestException "boom")+ `catch` \(_ :: TestException) -> pure ()+ Waiters.without (logEvent "quiesced")+ events @?= ["quiesced"]+ ]+ ]
tricorder.cabal view
@@ -5,13 +5,13 @@ -- see: https://github.com/sol/hpack name: tricorder-version: 0.4.1.1+version: 0.5.0.0 synopsis: Continuous Haskell build status, diagnostics, and tests via a shared daemon description: tricorder rebuilds your Haskell project continuously and surfaces build status, diagnostics, test results, and documentation - for developers and LLM coding agents. Like ghcid and ghciwatch it reloads on every change, but builds run in a background daemon so multiple clients (an interactive TUI, a status CLI, an agent skill) share a single build state without triggering redundant rebuilds. It discovers components across multi-package cabal.project workspaces automatically and ships context-friendly output for agentic use via the CLI. category: Development homepage: https://github.com/tweag/tricorder#readme bug-reports: https://github.com/tweag/tricorder/issues-author: Victor Nascimento Bakke+author: Christian Georgii maintainer: victor.bakke@tweag.io license: MIT license-file: LICENSE@@ -40,6 +40,8 @@ Tricorder.Build.Test Tricorder.CLI.App Tricorder.CLI.Arguments+ Tricorder.CLI.Arguments.Daemon+ Tricorder.CLI.Arguments.OutputFormat Tricorder.CLI.Daemon Tricorder.CLI.Main Tricorder.CLI.Operations@@ -72,15 +74,28 @@ Tricorder.Runtime Tricorder.Session Tricorder.Session.CabalFile- Tricorder.Session.Command+ Tricorder.Session.Command.RenderedCommand+ Tricorder.Session.CommandConfig+ Tricorder.Session.CommandTemplate Tricorder.Session.Config Tricorder.Session.GenerateWithHpack Tricorder.Session.Hooks Tricorder.Session.IdleTimeout+ Tricorder.Session.Repl Tricorder.Session.ReplBuildDir+ Tricorder.Session.StackProject+ Tricorder.Session.Stage+ Tricorder.Session.Stage.Build.Command+ Tricorder.Session.Stage.Build.Session+ Tricorder.Session.Stage.Eval.Command+ Tricorder.Session.Stage.Eval.Session+ Tricorder.Session.Stage.Test.Command+ Tricorder.Session.Stage.Test.Config+ Tricorder.Session.Stage.Test.Session Tricorder.Session.Target Tricorder.Session.TestTarget Tricorder.Session.TestTimeout+ Tricorder.Session.Util Tricorder.Session.WatchDirs Tricorder.Session.WatchExclusionPatterns Tricorder.Socket.Client@@ -129,7 +144,7 @@ , atelier-core ==0.7.* , atelier-prelude ==0.4.* , base >=4.18 && <4.23- , brick >=2.10 && <2.14+ , brick ==3.0.* , bytestring >=0.11 && <0.13 , casing ==0.1.* , containers >=0.6 && <0.9@@ -258,8 +273,12 @@ Unit.Tricorder.Daemon.TestRunnerSpec Unit.Tricorder.Daemon.WatchSpec Unit.Tricorder.Session.CabalFileSpec- Unit.Tricorder.Session.CommandSpec+ Unit.Tricorder.Session.CommandTemplateSpec Unit.Tricorder.Session.Helpers+ Unit.Tricorder.Session.ReplSpec+ Unit.Tricorder.Session.Stage.Build.CommandSpec+ Unit.Tricorder.Session.Stage.Eval.CommandSpec+ Unit.Tricorder.Session.Stage.Test.CommandSpec Unit.Tricorder.Session.TargetSpec Unit.Tricorder.Session.TestTargetSpec Unit.Tricorder.Session.WatchDirsSpec@@ -309,7 +328,6 @@ , effectful-core ==2.7.* , effectful-plugin ==2.2.* , filepath >=1.4 && <1.6- , hspec ==2.11.* , megaparsec >=9.7 && <9.9 , process ==1.6.* , regex-tdfa >=1.3.2.5 && <1.4@@ -317,7 +335,7 @@ , tar >=0.6 && <0.8 , tasty ==1.5.* , tasty-discover ==5.2.*- , tasty-hspec ==1.2.*+ , tasty-hunit >=0.10.2 && <0.11 , text ==2.1.* , time >=1.12 && <1.17 , time-units ==1.0.*