diff --git a/README.md b/README.md index d6f2881..0ca14d2 100644 --- a/README.md +++ b/README.md @@ -67,32 +67,26 @@ func main() { cacheConfig := sarah.NewCacheConfig() storage := sarah.NewUserContextStorage(cacheConfig) - // A helper to stash sarah.RunnerOptions for later use. - options := sarah.NewRunnerOptions() - // Setup Bot with slack adapter and default storage. bot, err := sarah.NewBot(adapter, sarah.BotWithStorage(storage)) if err != nil { panic(fmt.Errorf("faileld to setup Slack Bot: %s", err.Error())) } - options.Append(sarah.WithBot(bot)) + sarah.RegisterBot(bot) // Setup .hello command hello := &HelloCommand{} bot.AppendCommand(hello) - + // Setup properties to setup .guess command on the fly - options.Append(sarah.WithCommandProps(GuessProps)) + sarah.RegisterCommandProps(GuessProps) - // Setup sarah.Runner. - runnerConfig := sarah.NewConfig() - runner, err := sarah.NewRunner(runnerConfig, options.Arg()) + // Run + config := sarah.NewConfig() + err = sarah.Run(context.TODO(), config) if err != nil { - panic(fmt.Errorf("failed to initialize Runner: %s", err.Error())) + panic(fmt.Errorf("failed to run: %s", err.Error())) } - - // Run sarah.Runner. - runner.Run(context.TODO()) } var GuessProps = sarah.NewCommandPropsBuilder(). diff --git a/bot.go b/bot.go index 730648d..cccb013 100644 --- a/bot.go +++ b/bot.go @@ -5,18 +5,18 @@ import ( "golang.org/x/net/context" ) -// Bot provides interface for each bot implementation. -// Instance of concrete type can be fed to sarah.Runner to have its lifecycle under control. -// Multiple Bot implementation may be registered to single Runner. +// Bot provides an interface that each bot implementation must satisfy. +// Instance of concrete type can be registered via sarah.RegisterBot() to have its lifecycle under control. +// Multiple Bot implementation may be registered by multiple sarah.RegisterBot() calls. type Bot interface { // BotType represents what this Bot implements. e.g. slack, gitter, cli, etc... // This can be used as a unique ID to distinguish one from another. BotType() BotType - // Respond receives user input, look for corresponding command, execute it, and send result back to user if possible. + // Respond receives user input, look for the corresponding command, execute it, and send the result back to the user if possible. Respond(context.Context, Input) error - // SendMessage sends message to destination depending on the Bot implementation. + // SendMessage sends given message to the destination depending on the Bot implementation. // This is mainly used to send scheduled task's result. // Be advised: this method may be called simultaneously from multiple workers. SendMessage(context.Context, Output) @@ -24,15 +24,22 @@ type Bot interface { // AppendCommand appends given Command implementation to Bot internal stash. // Stashed commands are checked against user input in Bot.Respond, and if Command.Match returns true, the // Command is considered as "corresponds" to the input, hence its Command.Execute is called and the result is - // sent back to user. + // sent back to the user. AppendCommand(Command) - // Run is called on Runner.Run to let this Bot interact with corresponding service provider. - // For example, this is where Bot or Bot's corresponding Adapter initiates connection with service provider. - // This may run in a blocking manner til given context is canceled since a new goroutine is allocated for this task. - // When the service provider sends message to us, convert that message payload to Input and send to Input channel. - // Runner will receive the Input instance and proceed to find and execute corresponding command. - Run(context.Context, func(Input) error, func(error)) + // Run is called on sarah.Run() to let this Bot start interacting with corresponding service provider. + // When the service provider sends a message to us, convert that message payload to sarah.Input and send to inputReceiver. + // An internal worker will receive the Input instance and proceed to find and execute the corresponding command. + // The worker is managed by go-sarah's core; Bot/Adapter developers do not have to worry about implementing one. + // + // sarah.Run() allocates a new goroutine for each bot so this method can block til interaction ends. + // When this method returns, the interaction is considered finished. + // + // The bot lifecycle is entirely managed by go-sarah's core. + // On critical situation, notify such event via notifyErr and let go-sarah's core handle the error. + // When the bot is indeed in a critical state and cannot proceed further operation, ctx is canceled by go-sarah. + // Bot/Adapter developers may listen to this ctx.Done() to clean up its internal resources. + Run(ctx context.Context, inputReceiver func(Input) error, notifyErr func(error)) } type defaultBot struct { @@ -185,3 +192,5 @@ func NewSuppressedResponseWithNext(next ContextualFunc) *CommandResponse { UserContext: NewUserContext(next), } } + +type botRunner struct{} diff --git a/error.go b/error.go index 6b59304..23de356 100644 --- a/error.go +++ b/error.go @@ -1,6 +1,8 @@ package sarah -import "fmt" +import ( + "fmt" +) // BotNonContinuableError represents critical error that Bot can't continue its operation. // When Runner receives this, it must stop corresponding Bot, and should inform administrator by available mean. diff --git a/examples/simple/main.go b/examples/simple/main.go index 2deb8a8..1d603ed 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -7,13 +7,13 @@ import ( "flag" "github.com/oklahomer/go-sarah" "github.com/oklahomer/go-sarah/alerter/line" - "github.com/oklahomer/go-sarah/examples/simple/plugins/count" + _ "github.com/oklahomer/go-sarah/examples/simple/plugins/count" "github.com/oklahomer/go-sarah/examples/simple/plugins/echo" - "github.com/oklahomer/go-sarah/examples/simple/plugins/fixedtimer" - "github.com/oklahomer/go-sarah/examples/simple/plugins/guess" - "github.com/oklahomer/go-sarah/examples/simple/plugins/hello" - "github.com/oklahomer/go-sarah/examples/simple/plugins/morning" - "github.com/oklahomer/go-sarah/examples/simple/plugins/timer" + _ "github.com/oklahomer/go-sarah/examples/simple/plugins/fixedtimer" + _ "github.com/oklahomer/go-sarah/examples/simple/plugins/guess" + _ "github.com/oklahomer/go-sarah/examples/simple/plugins/hello" + _ "github.com/oklahomer/go-sarah/examples/simple/plugins/morning" + _ "github.com/oklahomer/go-sarah/examples/simple/plugins/timer" "github.com/oklahomer/go-sarah/examples/simple/plugins/todo" "github.com/oklahomer/go-sarah/log" "github.com/oklahomer/go-sarah/slack" @@ -43,7 +43,7 @@ func newMyConfig() *myConfig { } func main() { - var path = flag.String("config", "", "apth to apllication configuration file.") + var path = flag.String("config", "", "path to application configuration file.") flag.Parse() if *path == "" { panic("./bin/examples -config=/path/to/config/app.yml") @@ -55,13 +55,9 @@ func main() { panic(err) } - // A handy helper that holds arbitrary amount of RunnerOptions. - runnerOptions := sarah.NewRunnerOptions() - // When Bot encounters critical states, send alert to LINE. // Any number of Alerter implementation can be registered. - alerter := line.New(config.LineAlerter) - runnerOptions.Append(sarah.WithAlerter(alerter)) + sarah.RegisterAlerter(line.New(config.LineAlerter)) // Setup storage that can be shared among different Bot implementation. storage := sarah.NewUserContextStorage(config.CacheConfig) @@ -77,45 +73,19 @@ func main() { slackBot.AppendCommand(todoCmd) // Register bot to run. - runnerOptions.Append(sarah.WithBot(slackBot)) - - // Setup some plugins to build on the fly. - // Each configuration file, if exists, is subject to supervise. - // If updated, Command is re-built with new configuration. - runnerOptions.Append(sarah.WithCommandProps(hello.SlackProps)) - runnerOptions.Append(sarah.WithCommandProps(morning.SlackProps)) - runnerOptions.Append(sarah.WithCommandProps(count.SlackProps)) - runnerOptions.Append(sarah.WithCommandProps(guess.SlackProps)) - - // Setup scheduled tasks. - // Each configuration file, if exists, is subject to supervise. - // If updated, Command is re-built with new configuration. - runnerOptions.Append(sarah.WithScheduledTaskProps(timer.SlackProps)) - runnerOptions.Append(sarah.WithScheduledTaskProps(fixedtimer.SlackProps)) + sarah.RegisterBot(slackBot) // Directly add Command to Bot. // This Command is not subject to config file supervision. slackBot.AppendCommand(echo.Command) - // Setup sarah.Runner. - runner, err := sarah.NewRunner(config.Runner, runnerOptions.Arg()) + // Run + ctx, cancel := context.WithCancel(context.Background()) + err = sarah.Run(ctx, config.Runner) if err != nil { panic(err) } - // Run sarah.Runner. - run(runner) -} - -func run(runner sarah.Runner) { - ctx, cancel := context.WithCancel(context.Background()) - runnerStop := make(chan struct{}) - go func() { - // Blocks til all belonging Bots stop, or context is canceled. - runner.Run(ctx) - runnerStop <- struct{}{} - }() - c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) signal.Notify(c, syscall.SIGTERM) @@ -125,9 +95,6 @@ func run(runner sarah.Runner) { log.Info("Stopping due to signal reception.") cancel() - case <-runnerStop: - log.Error("Runner stopped.") - } } diff --git a/examples/simple/plugins/count/props.go b/examples/simple/plugins/count/props.go index c1a17c8..df1ba98 100644 --- a/examples/simple/plugins/count/props.go +++ b/examples/simple/plugins/count/props.go @@ -56,3 +56,8 @@ var GitterProps = sarah.NewCommandPropsBuilder(). return gitter.NewStringResponse(fmt.Sprint(globalCounter.increment())), nil }). MustBuild() + +func init() { + sarah.RegisterCommandProps(SlackProps) + sarah.RegisterCommandProps(GitterProps) +} diff --git a/examples/simple/plugins/fixedtimer/props.go b/examples/simple/plugins/fixedtimer/props.go index 9032aeb..dbf67de 100644 --- a/examples/simple/plugins/fixedtimer/props.go +++ b/examples/simple/plugins/fixedtimer/props.go @@ -36,3 +36,7 @@ var SlackProps = sarah.NewScheduledTaskPropsBuilder(). }). Schedule("@every 1m"). MustBuild() + +func init() { + sarah.RegisterScheduledTaskProps(SlackProps) +} diff --git a/examples/simple/plugins/guess/props.go b/examples/simple/plugins/guess/props.go index 010bf9f..103a296 100644 --- a/examples/simple/plugins/guess/props.go +++ b/examples/simple/plugins/guess/props.go @@ -62,3 +62,7 @@ func guessFunc(_ context.Context, input sarah.Input, answer int) (*sarah.Command return slack.NewStringResponseWithNext("Bigger!", retry), nil } } + +func init() { + sarah.RegisterCommandProps(SlackProps) +} diff --git a/examples/simple/plugins/hello/props.go b/examples/simple/plugins/hello/props.go index 8caa6b2..52695b8 100644 --- a/examples/simple/plugins/hello/props.go +++ b/examples/simple/plugins/hello/props.go @@ -31,3 +31,7 @@ var SlackProps = sarah.NewCommandPropsBuilder(). return slack.NewStringResponse("Hello, 世界"), nil }). MustBuild() + +func init() { + sarah.RegisterCommandProps(SlackProps) +} diff --git a/examples/simple/plugins/morning/props.go b/examples/simple/plugins/morning/props.go index 8d9789c..7a23cef 100644 --- a/examples/simple/plugins/morning/props.go +++ b/examples/simple/plugins/morning/props.go @@ -34,3 +34,7 @@ var SlackProps = sarah.NewCommandPropsBuilder(). return slack.NewStringResponse("Good morning."), nil }). MustBuild() + +func init() { + sarah.RegisterCommandProps(SlackProps) +} diff --git a/examples/simple/plugins/timer/props.go b/examples/simple/plugins/timer/props.go index 183742c..d395c5c 100644 --- a/examples/simple/plugins/timer/props.go +++ b/examples/simple/plugins/timer/props.go @@ -39,3 +39,7 @@ var SlackProps = sarah.NewScheduledTaskPropsBuilder(). }, nil }). MustBuild() + +func init() { + sarah.RegisterScheduledTaskProps(SlackProps) +} diff --git a/examples/status/handler.go b/examples/status/handler.go index 9330e86..2960818 100644 --- a/examples/status/handler.go +++ b/examples/status/handler.go @@ -8,16 +8,7 @@ import ( "runtime" ) -// statusGetter defines an interface that returns sarah.Status, which is satisfied by sarah.Runner. -// While the caller of setStatusHandler passes sarah.Runner directly, -// setStatusHandler receives it as a statusGetter interface so nothing nasty can be done against sarah.Runner. -type statusGetter interface { - Status() sarah.Status -} - -var _ statusGetter = sarah.Runner(nil) - -// setStatusHandler sets an endpoint that returns current status of sarah.Runner, its belonging sarah.Bots and sarah.Worker. +// setStatusHandler sets an endpoint that returns current status of go-sarah, its belonging sarah.Bot implementations and sarah.Worker. // // curl -s -XGET "http://localhost:8080/status" | jq . // { @@ -62,9 +53,9 @@ var _ statusGetter = sarah.Runner(nil) // ] // } // } -func setStatusHandler(mux *http.ServeMux, sg statusGetter, ws *workerStats) { +func setStatusHandler(mux *http.ServeMux, ws *workerStats) { mux.HandleFunc("/status", func(writer http.ResponseWriter, request *http.Request) { - runnerStatus := sg.Status() + runnerStatus := sarah.CurrentStatus() systemStatus := &botSystemStatus{} systemStatus.Running = runnerStatus.Running for _, b := range runnerStatus.Bots { diff --git a/examples/status/main.go b/examples/status/main.go index b02cacf..300b82e 100644 --- a/examples/status/main.go +++ b/examples/status/main.go @@ -1,8 +1,7 @@ /* -Package main provides an example that uses Runner.Status() -to return current sarah.Runner and its belonging Bot status via HTTP server. +Package main provides an example that uses sarah.CurrentStatus() to get current go-sarah and its belonging Bot's status via HTTP server. -In this example two bots, slack and nullBot, are registered to sarah.Runner and become subject to supervise. +In this example two bots, slack and nullBot, are registered to go-sarah and become subject to supervise. See handler.go for Runner.Status() usage. */ package main @@ -35,19 +34,16 @@ func main() { panic(err) } - // A handy struct that stores all sarah.RunnerOption to be passed to sarah.Runner - runnerOptions := sarah.NewRunnerOptions() - // Setup a bot nullBot := &nullBot{} - runnerOptions.Append(sarah.WithBot(nullBot)) + sarah.RegisterBot(nullBot) // Setup another bot slackBot, err := setupSlackBot(cfg) if err != nil { panic(err) } - runnerOptions.Append(sarah.WithBot(slackBot)) + sarah.RegisterBot(slackBot) // Setup worker workerReporter := &workerStats{} @@ -56,19 +52,16 @@ func main() { if err != nil { panic(err) } - runnerOptions.Append(sarah.WithWorker(worker)) + sarah.RegisterWorker(worker) // Setup a Runner to run and supervise above bots - runner, err := sarah.NewRunner(cfg.Runner, runnerOptions.Arg()) + err = sarah.Run(ctx, cfg.Runner) if err != nil { panic(err) } - // Run sarah.Runner - go runner.Run(ctx) - // Run HTTP server that reports current status - server := newServer(runner, workerReporter) + server := newServer(workerReporter) go server.Run(ctx) // Wait til signal reception diff --git a/examples/status/server_go17.go b/examples/status/server_go17.go index 218e266..e8be0a4 100644 --- a/examples/status/server_go17.go +++ b/examples/status/server_go17.go @@ -3,7 +3,6 @@ package main import ( - "github.com/oklahomer/go-sarah" "golang.org/x/net/context" "net/http" ) @@ -12,9 +11,9 @@ type server struct { sv *http.Server } -func newServer(runner sarah.Runner, wsr *workerStats) *server { +func newServer(wsr *workerStats) *server { mux := http.NewServeMux() - setStatusHandler(mux, runner, wsr) + setStatusHandler(mux, wsr) return &server{ sv: &http.Server{Addr: ":8080", Handler: mux}, } diff --git a/examples/status/server_go18.go b/examples/status/server_go18.go index 10238fd..6d39803 100644 --- a/examples/status/server_go18.go +++ b/examples/status/server_go18.go @@ -4,7 +4,6 @@ package main import ( "context" - "github.com/oklahomer/go-sarah" "github.com/oklahomer/go-sarah/log" "net/http" "runtime" @@ -14,9 +13,9 @@ type server struct { sv *http.Server } -func newServer(runner sarah.Runner, wsr *workerStats) *server { +func newServer(wsr *workerStats) *server { mux := http.NewServeMux() - setStatusHandler(mux, runner, wsr) + setStatusHandler(mux, wsr) return &server{ sv: &http.Server{Addr: ":8080", Handler: mux}, } diff --git a/plugins/worldweather/weather.go b/plugins/worldweather/weather.go index b5b0475..0323354 100644 --- a/plugins/worldweather/weather.go +++ b/plugins/worldweather/weather.go @@ -6,20 +6,18 @@ When weather API returns response that indicates input error, this command retur so the user's next input will be directly fed to the designated function, which actually is equivalent to second command call in this time. To see detailed implementation, read corresponding code where this command is calling slack.NewStringResponseWithNext. -When this sarah.CommandProps is passed to sarah.Runner, sarah.Runner tries to read configuration file and map content to weather.CommandConfig. +When this sarah.CommandProps is passed to sarah.RegisterCommandProps, go-sarah tries to read configuration file and map content to weather.CommandConfig. Setup should be somewhat like below: - options := sarah.NewRunnerOptions - - options.Append(sarah.WithCommandProps(hello.SlackProps)) - options.Append(sarah.WithCommandProps(echo.SlackProps)) - options.Append(sarah.WithCommandProps(worldweather.SlackProps)) + sarah.RegisterCommandProps(hello.SlackProps) + sarah.RegisterCommandProps(echo.SlackProps) + sarah.RegisterCommandProps(worldweather.SlackProps) // Config.PluginConfigRoot must be set to read configuration file for this command. // Runner searches for configuration file located at config.PluginConfigRoot + "/slack/weather.(yaml|yml|json)". config := sarah.NewConfig() config.PluginConfigRoot = "/path/to/config/" // Or do yaml.Unmarshal(fileBuf, config), json.Unmarshal(fileBuf, config) - runner, err := sarah.NewRunner(config, options.Arg()) + err := sarah.Run(context.TODO(), config) */ package worldweather diff --git a/runner.go b/runner.go index fcf9b94..217b18a 100644 --- a/runner.go +++ b/runner.go @@ -19,7 +19,9 @@ import ( "time" ) -// Config contains some configuration variables for Runner. +var options = &optionHolder{} + +// Config contains some basic configuration variables for go-sarah. type Config struct { PluginConfigRoot string `json:"plugin_config_root" yaml:"plugin_config_root"` TimeZone string `json:"timezone" yaml:"timezone"` @@ -36,229 +38,162 @@ func NewConfig() *Config { } } -// Runner is the core of sarah. -// -// This is responsible for each Bot implementation's lifecycle and plugin execution; -// Bot is responsible for bot-specific implementation such as connection handling, message reception and sending. -// -// Developers can register desired number of Bots and Commands to create own bot experience. -// While developers may provide own implementation for interfaces in this project to customize behavior, -// this particular interface is not meant to be implemented and replaced. -// See https://github.com/oklahomer/go-sarah/pull/47 -type Runner interface { - // Run starts Bot interaction. - // At this point Runner starts its internal workers and schedulers, runs each bot, and starts listening to incoming messages. - Run(context.Context) - - // Status returns the status of Runner and belonging Bots. - // The returned Status value represents a snapshot of the status when this method is called, - // which means each field value is not subject to update. - // To reflect the latest status, this is recommended to call this method whenever the value is needed. - Status() Status +// optionHolder is a struct that stashes given options before go-sarah's initialization. +// This was formally called RunnerOptions and was provided publicly, but is now private in favor of https://github.com/oklahomer/go-sarah/issues/72 +// Calls to its methods are thread-safe. +type optionHolder struct { + mutex sync.RWMutex + stashed []func(*runner) error } -type runner struct { - config *Config - bots []Bot - worker workers.Worker - watcher watchers.Watcher - commandProps map[BotType][]*CommandProps - scheduledTaskPrps map[BotType][]*ScheduledTaskProps - scheduledTasks map[BotType][]ScheduledTask - alerters *alerters - status *status -} - -// NewRunner creates and return new instance that satisfies Runner interface. -// -// The reason for returning interface instead of concrete implementation -// is to avoid developers from executing RunnerOption outside of NewRunner, -// where sarah can not be aware of and severe side-effect may occur. -// -// Ref. https://github.com/oklahomer/go-sarah/pull/47 -// -// So the aim is not to let developers switch its implementations. -func NewRunner(config *Config, options ...RunnerOption) (Runner, error) { - r := &runner{ - config: config, - bots: []Bot{}, - worker: nil, - commandProps: make(map[BotType][]*CommandProps), - scheduledTaskPrps: make(map[BotType][]*ScheduledTaskProps), - scheduledTasks: make(map[BotType][]ScheduledTask), - alerters: &alerters{}, - status: &status{}, - } - - for _, opt := range options { - err := opt(r) - if err != nil { - return nil, err - } - } +func (o *optionHolder) register(opt func(*runner) error) { + o.mutex.Lock() + defer o.mutex.Unlock() - return r, nil + o.stashed = append(o.stashed, opt) } -// RunnerOption defines a function signature that NewRunner's functional option must satisfy. -type RunnerOption func(*runner) error +func (o *optionHolder) apply(r *runner) error { + o.mutex.Lock() + defer o.mutex.Unlock() -// RunnerOptions stashes group of RunnerOption for later use with NewRunner(). -// -// On typical setup, especially when a process consists of multiple Bots and Commands, each construction step requires more lines of codes. -// Each step ends with creating new RunnerOption instance to be fed to NewRunner(), but as code gets longer it gets harder to keep track of each RunnerOption. -// In that case RunnerOptions becomes a handy helper to temporary stash RunnerOption. -// -// options := NewRunnerOptions() -// -// // 5-10 lines of codes to configure Slack bot. -// slackBot, _ := sarah.NewBot(slack.NewAdapter(slackConfig), sarah.BotWithStorage(storage)) -// options.Append(sarah.WithBot(slackBot)) -// -// // Here comes other 5-10 codes to configure another bot. -// myBot, _ := NewMyBot(...) -// optionsAppend(sarah.WithBot(myBot)) -// -// // Some more codes to register Commands/ScheduledTasks. -// myTask := customizedTask() -// options.Append(sarah.WithScheduledTask(myTask)) -// -// // Finally feed stashed options to NewRunner at once -// runner, _ := NewRunner(sarah.NewConfig(), options.Arg()) -// runner.Run(ctx) -type RunnerOptions []RunnerOption - -// NewRunnerOptions creates and returns new RunnerOptions instance. -func NewRunnerOptions() *RunnerOptions { - return &RunnerOptions{} -} + for _, v := range o.stashed { + e := v(r) + if e != nil { + return e + } + } -// Append adds given RunnerOption to internal stash. -// When more than two RunnerOption instances are stashed, they are executed in the order of addition. -func (options *RunnerOptions) Append(opt RunnerOption) { - *options = append(*options, opt) + return nil } -// Arg returns stashed RunnerOptions in a form that can be directly fed to NewRunner's second argument. -func (options *RunnerOptions) Arg() RunnerOption { - return func(r *runner) error { - for _, opt := range *options { - err := opt(r) - if err != nil { - return err - } - } - +// RegisterAlerter registers given sarah.Alerter implementation. +// When registered sarah.Bot implementation encounters critical state, given alerter is called to notify such state. +func RegisterAlerter(alerter Alerter) { + options.register(func(r *runner) error { + r.alerters.appendAlerter(alerter) return nil - } + }) } -// WithBot creates RunnerOption that feeds given Bot implementation to Runner. -func WithBot(bot Bot) RunnerOption { - return func(r *runner) error { +// RegisterBot registers given sarah.Bot implementation to be run on sarah.Run(). +// This may be called multiple times to register as many bot instances as wanted. +// When a Bot with same sarah.BotType is already registered, this returns error on sarah.Run(). +func RegisterBot(bot Bot) { + options.register(func(r *runner) error { r.bots = append(r.bots, bot) return nil - } + }) } -// WithCommandProps creates RunnerOption that feeds given CommandProps to Runner. -// Command is built on runner.Run with given CommandProps. -// This props is re-used when configuration file is updated and Command needs to be re-built. -func WithCommandProps(props *CommandProps) RunnerOption { - return func(r *runner) error { +// RegisterCommandProps registers given sarah.CommandProps to build sarah.Command on sarah.Run(). +// This props is re-used when configuration file is updated and a corresponding sarah.Command needs to be re-built. +func RegisterCommandProps(props *CommandProps) { + options.register(func(r *runner) error { stashed, ok := r.commandProps[props.botType] if !ok { stashed = []*CommandProps{} } r.commandProps[props.botType] = append(stashed, props) return nil - } + }) } -// WithScheduledTaskProps creates RunnerOption that feeds given ScheduledTaskProps to Runner. -// ScheduledTask is built on runner.Run with given ScheduledTaskProps. -// This props is re-used when configuration file is updated and ScheduledTask needs to be re-built. -func WithScheduledTaskProps(props *ScheduledTaskProps) RunnerOption { - return func(r *runner) error { - stashed, ok := r.scheduledTaskPrps[props.botType] +// RegisterScheduledTask registers given sarah.ScheduledTask. +// On sarah.Run(), schedule is set for this task. +func RegisterScheduledTask(botType BotType, task ScheduledTask) { + options.register(func(r *runner) error { + tasks, ok := r.scheduledTasks[botType] if !ok { - stashed = []*ScheduledTaskProps{} + tasks = []ScheduledTask{} } - r.scheduledTaskPrps[props.botType] = append(stashed, props) + r.scheduledTasks[botType] = append(tasks, task) return nil - } + }) } -// WithScheduledTask creates RunnerOperation that feeds given ScheduledTask to Runner. -func WithScheduledTask(botType BotType, task ScheduledTask) RunnerOption { - return func(r *runner) error { - tasks, ok := r.scheduledTasks[botType] +// RegisterScheduledTaskProps registers given sarah.ScheduledTaskProps to build sarah.ScheduledTask on sarah.Run(). +// This props is re-used when configuration file is updated and a corresponding sarah.ScheduledTask needs to be re-built. +func RegisterScheduledTaskProps(props *ScheduledTaskProps) { + options.register(func(r *runner) error { + stashed, ok := r.scheduledTaskProps[props.botType] if !ok { - tasks = []ScheduledTask{} + stashed = []*ScheduledTaskProps{} } - r.scheduledTasks[botType] = append(tasks, task) + r.scheduledTaskProps[props.botType] = append(stashed, props) return nil - } + }) } -// WithAlerter creates RunnerOperation that feeds given Alerter implementation to Runner. -func WithAlerter(alerter Alerter) RunnerOption { - return func(r *runner) error { - r.alerters.appendAlerter(alerter) +// RegisterWatcher registers given watchers.Watcher implementation. +// When this is not called but Config.PluginConfigRoot is still set, Sarah creates watcher with default configuration on sarah.Run(). +func RegisterWatcher(watcher watchers.Watcher) { + options.register(func(r *runner) error { + r.watcher = watcher return nil - } + }) } -// WithWorker creates RunnerOperation that feeds given Worker implementation to Runner. -// If no WithWorker is supplied, Runner creates worker with default configuration on runner.Run. -func WithWorker(worker workers.Worker) RunnerOption { - return func(r *runner) error { +// RegisterWorker registers given workers.Worker implementation. +// When this is not called, a worker instance with default setting is used. +func RegisterWorker(worker workers.Worker) { + options.register(func(r *runner) error { r.worker = worker return nil - } + }) } -// WithWatcher creates RunnerOption that feeds given Watcher implementation to Runner. -// If Config.PluginConfigRoot is set without WithWatcher option, Runner creates Watcher with default configuration on Runner.Run. -func WithWatcher(watcher watchers.Watcher) RunnerOption { - return func(r *runner) error { - r.watcher = watcher - return nil +// Run is a non-blocking function that starts running go-sarah's process with pre-registered options. +// Workers, schedulers and other required resources for bot interaction starts running on this function call. +// This returns error when bot interaction cannot start; No error is returned when process starts successfully. +// +// Refer to ctx.Done() or sarah.CurrentStatus() to reference current running status. +// +// To control its lifecycle, a developer may cancel ctx to stop go-sarah at any moment. +// When bot interaction stops unintentionally without such context cancellation, +// the critical state is notified to administrators via registered sarah.Alerter. +// This is recommended to register multiple sarah.Alerter implementations to make sure critical states are notified. +func Run(ctx context.Context, config *Config) error { + err := runnerStatus.start() + if err != nil { + return err } -} -func (r *runner) botCommandProps(botType BotType) []*CommandProps { - if props, ok := r.commandProps[botType]; ok { - return props + runner, err := newRunner(ctx, config) + if err != nil { + return err } - return []*CommandProps{} -} + go runner.run(ctx) -func (r *runner) botScheduledTaskProps(botType BotType) []*ScheduledTaskProps { - if props, ok := r.scheduledTaskPrps[botType]; ok { - return props - } - return []*ScheduledTaskProps{} + return nil } -func (r *runner) botScheduledTasks(botType BotType) []ScheduledTask { - if tasks, ok := r.scheduledTasks[botType]; ok { - return tasks +func newRunner(ctx context.Context, config *Config) (*runner, error) { + loc, locErr := time.LoadLocation(config.TimeZone) + if locErr != nil { + return nil, fmt.Errorf("given timezone can't be converted to time.Location: %s", locErr.Error()) } - return []ScheduledTask{} -} -func (r *runner) Status() Status { - return r.status.snapshot() -} + r := &runner{ + config: config, + bots: []Bot{}, + worker: nil, + commandProps: make(map[BotType][]*CommandProps), + scheduledTaskProps: make(map[BotType][]*ScheduledTaskProps), + scheduledTasks: make(map[BotType][]ScheduledTask), + alerters: &alerters{}, + scheduler: runScheduler(ctx, loc), + } -func (r *runner) Run(ctx context.Context) { - r.status.start() + err := options.apply(r) + if err != nil { + return nil, fmt.Errorf("failed to apply option: %s", err.Error()) + } if r.worker == nil { w, e := workers.Run(ctx, workers.NewConfig()) if e != nil { - panic(fmt.Sprintf("worker could not run: %s", e.Error())) + return nil, fmt.Errorf("worker could not run: %s", e.Error()) } r.worker = w @@ -267,90 +202,133 @@ func (r *runner) Run(ctx context.Context) { if r.config.PluginConfigRoot != "" && r.watcher == nil { w, e := watchers.Run(ctx) if e != nil { - panic(fmt.Sprintf("watcher could not run: %s", e.Error())) + return nil, fmt.Errorf("watcher could not run: %s", e.Error()) } r.watcher = w } - loc, locErr := time.LoadLocation(r.config.TimeZone) - if locErr != nil { - panic(fmt.Sprintf("given timezone can't be converted to time.Location: %s", locErr.Error())) + return r, nil +} + +type runner struct { + config *Config + bots []Bot + worker workers.Worker + watcher watchers.Watcher + commandProps map[BotType][]*CommandProps + scheduledTaskProps map[BotType][]*ScheduledTaskProps + scheduledTasks map[BotType][]ScheduledTask + alerters *alerters + scheduler scheduler +} + +func (r *runner) botCommandProps(botType BotType) []*CommandProps { + if props, ok := r.commandProps[botType]; ok { + return props + } + return []*CommandProps{} +} + +func (r *runner) botScheduledTaskProps(botType BotType) []*ScheduledTaskProps { + if props, ok := r.scheduledTaskProps[botType]; ok { + return props + } + return []*ScheduledTaskProps{} +} + +func (r *runner) botScheduledTasks(botType BotType) []ScheduledTask { + if tasks, ok := r.scheduledTasks[botType]; ok { + return tasks } - taskScheduler := runScheduler(ctx, loc) + return []ScheduledTask{} +} +func (r *runner) run(ctx context.Context) { var wg sync.WaitGroup for _, bot := range r.bots { wg.Add(1) - botType := bot.BotType() - log.Infof("starting %s", botType.String()) + go func(b Bot) { + defer func() { + wg.Done() + runnerStatus.stopBot(b) + }() - // Each Bot has its own context propagating Runner's lifecycle. - botCtx, errNotifier := superviseBot(ctx, botType, r.alerters) + runnerStatus.addBot(b) + r.runBot(ctx, b) + }(bot) - // Prepare function that receives Input. - receiveInput := setupInputReceiver(botCtx, bot, r.worker) + } + wg.Wait() +} - // Run Bot - go runBot(botCtx, bot, receiveInput, errNotifier) - r.status.addBot(bot) +// runBot runs given Bot implementation in a blocking manner. +// This returns when bot stops. +func (r *runner) runBot(runnerCtx context.Context, bot Bot) { + log.Infof("Starting %s", bot.BotType()) + botCtx, errNotifier := superviseBot(runnerCtx, bot.BotType(), r.alerters) - // Setup config directory. - var configDir string - if r.config.PluginConfigRoot != "" { - configDir = filepath.Join(r.config.PluginConfigRoot, strings.ToLower(bot.BotType().String())) - } + // Setup config directory for this particular Bot. + var configDir string + if r.config.PluginConfigRoot != "" { + configDir = filepath.Join(r.config.PluginConfigRoot, strings.ToLower(bot.BotType().String())) + } - // Build commands with stashed CommandProps. - commandProps := r.botCommandProps(botType) - registerCommands(bot, commandProps, configDir) + // Build commands with stashed CommandProps. + commandProps := r.botCommandProps(bot.BotType()) + registerCommands(bot, commandProps, configDir) - // Register scheduled tasks. - tasks := r.botScheduledTasks(botType) - taskProps := r.botScheduledTaskProps(botType) - registerScheduledTasks(botCtx, bot, tasks, taskProps, taskScheduler, configDir) + // Register scheduled tasks. + tasks := r.botScheduledTasks(bot.BotType()) + taskProps := r.botScheduledTaskProps(bot.BotType()) + registerScheduledTasks(botCtx, bot, tasks, taskProps, r.scheduler, configDir) - // Supervise configuration files' directory for Command/ScheduledTask. - if configDir != "" { - callback := r.configUpdateCallback(botCtx, bot, taskScheduler) - err := r.watcher.Subscribe(botType.String(), configDir, callback) - if err != nil { - log.Errorf("Failed to watch %s: %s", configDir, err.Error()) - } - } + if configDir != "" { + go r.subscribeConfigDir(botCtx, bot, configDir) + } + + inputReceiver := setupInputReceiver(botCtx, bot, r.worker) + + // Run Bot in a panic-proof manner + func() { + defer func() { + // When Bot panics, recover and tell as much detailed information as possible via error notification channel. + // Notified channel sends alert to notify administrator. + if r := recover(); r != nil { + stack := []string{fmt.Sprintf("panic in bot: %s. %#v.", bot.BotType(), r)} - go func(c context.Context, b Bot, d string) { - select { - case <-c.Done(): - defer wg.Done() - - // When Bot stops, stop subscription for config file changes. - if d != "" { - err := r.watcher.Unsubscribe(b.BotType().String()) - if err != nil { - // Probably because Runner context is canceled, and its derived contexts are canceled simultaneously. - // In that case this warning is harmless since Watcher itself is canceled at this point. - log.Warnf("Failed to unsubscribe %s", err.Error()) + // Inform stack trace + for depth := 0; ; depth++ { + _, src, line, ok := runtime.Caller(depth) + if !ok { + break } + stack = append(stack, fmt.Sprintf(" -> depth:%d. file:%s. line:%d.", depth, src, line)) } - r.status.stopBot(b) + errNotifier(NewBotNonContinuableError(strings.Join(stack, "\n"))) } - }(botCtx, bot, configDir) - } - wg.Wait() - r.status.stop() + // Explicitly send *BotNonContinuableError to make sure bot context is canceled and administrators are notified. + // This is effective when Bot implementation stops running without notifying its critical state by sending *BotNonContinuableError to errNotifier. + // Error sent here is simply ignored when Bot context is already canceled by previous *BotNonContinuableError notification. + errNotifier(NewBotNonContinuableError(fmt.Sprintf("shutdown bot: %s", bot.BotType()))) + }() + + bot.Run(botCtx, inputReceiver, errNotifier) + }() } -func (r *runner) configUpdateCallback(botCtx context.Context, bot Bot, taskScheduler scheduler) func(string) { - return func(path string) { +// subscribeConfigDir listens to changes of configuration files under configDir. +// When a file is updated, a callback function reads the file content and apply changes to corresponding commands and scheduled tasks. +func (r *runner) subscribeConfigDir(botCtx context.Context, bot Bot, configDir string) { + callback := func(path string) { file, err := plainPathToFile(path) if err == errUnableToDetermineConfigFileFormat { log.Warnf("File under Config.PluginConfigRoot is updated, but file format can not be determined from its extension: %s.", path) return - } else if err == errUnableToDetermineConfigFileFormat { + } else if err == errUnsupportedConfigFileFormat { log.Warnf("File under Config.PluginConfigRoot is updated, but file format is not supported: %s.", path) return } else if err != nil { @@ -359,7 +337,7 @@ func (r *runner) configUpdateCallback(botCtx context.Context, bot Bot, taskSched } // TODO Consider wrapping below function calls with goroutine. - // Developer may update bunch of files under PluginConfigRoot at once. e.g. rsync whole all files under the directory. + // A developer may update bunch of files under PluginConfigRoot at once. e.g. rsync all files under the directory. // That makes series of callback function calls while each Command/ScheduledTask blocks config file while its execution. // See if that block is critical to watcher implementation. commandProps := r.botCommandProps(bot.BotType()) @@ -368,33 +346,24 @@ func (r *runner) configUpdateCallback(botCtx context.Context, bot Bot, taskSched } taskProps := r.botScheduledTaskProps(bot.BotType()) - if e := updateScheduledTaskConfig(botCtx, bot, taskProps, taskScheduler, file); e != nil { + if e := updateScheduledTaskConfig(botCtx, bot, taskProps, r.scheduler, file); e != nil { log.Errorf("Failed to update ScheduledTask config: %s", e.Error()) } } -} - -func runBot(ctx context.Context, bot Bot, receiveInput func(Input) error, errNotifier func(error)) { - // When bot panics, recover and tell as much detailed information as possible via error notification channel. - // Notified channel sends alert to notify administrator. - defer func() { - if r := recover(); r != nil { - stack := []string{fmt.Sprintf("panic in bot: %s. %#v.", bot.BotType(), r)} - - // Inform stack trace - for depth := 0; ; depth++ { - _, src, line, ok := runtime.Caller(depth) - if !ok { - break - } - stack = append(stack, fmt.Sprintf(" -> depth:%d. file:%s. line:%d.", depth, src, line)) - } - - errNotifier(NewBotNonContinuableError(strings.Join(stack, "\n"))) - } - }() + err := r.watcher.Subscribe(bot.BotType().String(), configDir, callback) + if err != nil { + log.Errorf("Failed to watch %s: %s", configDir, err.Error()) + return + } - bot.Run(ctx, receiveInput, errNotifier) + // When Bot stops, stop subscription for config file changes. + <-botCtx.Done() + err = r.watcher.Unsubscribe(bot.BotType().String()) + if err != nil { + // Probably because Runner context is canceled, and its derived contexts are canceled simultaneously. + // In that case this warning is harmless since Watcher itself is canceled at this point. + log.Warnf("Failed to unsubscribe %s", err.Error()) + } } func registerScheduledTask(botCtx context.Context, bot Bot, task ScheduledTask, taskScheduler scheduler) { diff --git a/runner_test.go b/runner_test.go index 2f7c30e..e5eac3a 100644 --- a/runner_test.go +++ b/runner_test.go @@ -8,6 +8,7 @@ import ( stdLogger "log" "os" "path/filepath" + "reflect" "regexp" "sync" "testing" @@ -18,6 +19,7 @@ func TestMain(m *testing.M) { oldLogger := log.GetLogger() defer log.SetLogger(oldLogger) + // Suppress log output in test by default l := stdLogger.New(ioutil.Discard, "dummyLog", 0) logger := log.NewWithStandardLogger(l) log.SetLogger(logger) @@ -27,6 +29,14 @@ func TestMain(m *testing.M) { os.Exit(code) } +func SetupAndRun(fnc func()) { + // Initialize package variables + runnerStatus = &status{} + options = &optionHolder{} + + fnc() +} + type DummyWorker struct { EnqueueFunc func(func()) error } @@ -55,1068 +65,1370 @@ func TestNewConfig(t *testing.T) { } } -func TestNewRunnerOptions(t *testing.T) { - options := NewRunnerOptions() - - if len(*options) != 0 { - t.Errorf("Size of stashed options should be 0 at first, but was %d.", len(*options)) +func Test_optionHolder_register(t *testing.T) { + opt := func(_ *runner) error { + return nil } -} - -func TestRunnerOptions_Append(t *testing.T) { - options := &RunnerOptions{} - options.Append(func(_ *runner) error { return nil }) + holder := &optionHolder{} + holder.register(opt) - if len(*options) != 1 { - t.Errorf("Size of stashed options should be 0 at first, but was %d.", len(*options)) + if len(holder.stashed) != 1 { + t.Fatalf("Expected number of options are not stashed: %d.", len(holder.stashed)) } -} - -func TestRunnerOptions_Arg(t *testing.T) { - options := &RunnerOptions{} - calledCnt := 0 - *options = append( - *options, - func(_ *runner) error { - calledCnt++ - return nil - }, - func(_ *runner) error { - calledCnt++ - return nil - }, - ) - _ = options.Arg()(&runner{}) - - if calledCnt != 2 { - t.Fatalf("Options are not properly called. Count: %d.", calledCnt) + if reflect.ValueOf(holder.stashed[0]).Pointer() != reflect.ValueOf(opt).Pointer() { + t.Error("Given option is not stashed.") } } -func TestRunnerOptions_Arg_WithError(t *testing.T) { - calledCnt := 0 - options := &RunnerOptions{ +func Test_optionHandler_apply(t *testing.T) { + called := 0 + expectedErr := errors.New("option application error") + holder := &optionHolder{} + holder.stashed = []func(*runner) error{ func(_ *runner) error { - calledCnt++ - return errors.New("something is wrong") + called++ + return nil }, func(_ *runner) error { - calledCnt++ - return nil + called++ + return expectedErr }, } + r := &runner{} - err := options.Arg()(&runner{}) + err := holder.apply(r) if err == nil { - t.Fatal("Error should be returned.") + t.Fatal("Expected error is not returned.") + } + if err != expectedErr { + t.Errorf("Unexpected error is not returned: %s.", err.Error()) } - if calledCnt != 1 { - t.Error("Construction should abort right after first error is returned, but seems like it continued.") + if called != 2 { + t.Errorf("Unexpected number of options are applied: %d.", called) } } -func TestNewRunner(t *testing.T) { - config := NewConfig() - r, err := NewRunner(config) +func TestRegisterAlerter(t *testing.T) { + SetupAndRun(func() { + alerter := &DummyAlerter{} + RegisterAlerter(alerter) + r := &runner{ + alerters: &alerters{}, + } - if err != nil { - t.Fatalf("Unexpected error: %#v.", err) - } + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } - if r == nil { - t.Fatal("NewRunner returned nil.") - } + if len(*r.alerters) != 1 { + t.Fatalf("Expected number of alerter is not registered: %d.", len(*r.alerters)) + } - impl, ok := r.(*runner) - if !ok { - t.Fatalf("Returned instance is not runner instance: %T", r) - } + if (*r.alerters)[0] != alerter { + t.Error("Given alerter is not registered.") + } + }) +} - if impl.config != config { - t.Errorf("Passed config is not set: %#v.", impl.config) - } +func TestRegisterBot(t *testing.T) { + SetupAndRun(func() { + bot := &DummyBot{} + RegisterBot(bot) + r := &runner{ + alerters: &alerters{}, + } - if impl.bots == nil { - t.Error("Bot slice is nil.") - } + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } - if impl.scheduledTasks == nil { - t.Error("scheduledTasks are not set.") - } + if len(r.bots) != 1 { + t.Fatalf("Expected number of bot is not registered: %d.", len(r.bots)) + } - if impl.status == nil { - t.Error("status is not set.") - } + if r.bots[0] != bot { + t.Error("Given bot is not registered.") + } + }) } -func TestNewRunner_WithRunnerOption(t *testing.T) { - called := false - config := NewConfig() - r, err := NewRunner( - config, - func(_ *runner) error { - called = true - return nil - }, - ) +func TestRegisterCommandProps(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "dummy" + props := &CommandProps{ + botType: botType, + } + RegisterCommandProps(props) + r := &runner{ + commandProps: map[BotType][]*CommandProps{}, + } - if r == nil { - t.Error("runner instance should be returned.") - } + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } - if called == false { - t.Error("RunnerOption is not called.") - } + if len(r.commandProps[botType]) != 1 { + t.Fatalf("Expected number of CommandProps is not registered: %d.", len(r.commandProps[botType])) + } - if err != nil { - t.Errorf("Unexpected error is returned: %#v.", err) - } + if r.commandProps[botType][0] != props { + t.Error("Given CommandProps is not registered.") + } + }) } -func TestNewRunner_WithRunnerFatalOptions(t *testing.T) { - called := false - optErr := errors.New("second RunnerOption returns this error") - config := NewConfig() - r, err := NewRunner( - config, - func(_ *runner) error { - called = true - return nil - }, - func(_ *runner) error { - return optErr - }, - ) +func TestRegisterScheduledTask(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "dummy" + task := &DummyScheduledTask{} + RegisterScheduledTask(botType, task) + r := &runner{ + scheduledTasks: map[BotType][]ScheduledTask{}, + } - if r != nil { - t.Error("runner instance should not be returned on error.") - } + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } - if called == false { - t.Error("RunnerOption is not called.") - } + if len(r.scheduledTasks[botType]) != 1 { + t.Fatalf("Expected number of ScheduledTask is not registered: %d.", len(r.scheduledTasks[botType])) + } - if err == nil { - t.Error("Error should be returned.") - } + if r.scheduledTasks[botType][0] != task { + t.Error("Given ScheduledTask is not registered.") + } + }) +} - if err != optErr { - t.Errorf("Expected error is not returned: %#v.", err) - } +func TestRegisterScheduledTaskProps(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "dummy" + props := &ScheduledTaskProps{ + botType: botType, + } + RegisterScheduledTaskProps(props) + r := &runner{ + scheduledTaskProps: map[BotType][]*ScheduledTaskProps{}, + } + + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } + + if len(r.scheduledTaskProps[botType]) != 1 { + t.Fatalf("Expected number of ScheduledTaskProps is not registered: %d.", len(r.scheduledTaskProps[botType])) + } + + if r.scheduledTaskProps[botType][0] != props { + t.Error("Given ScheduledTaskProps is not registered.") + } + }) } -func TestWithBot(t *testing.T) { - bot := &DummyBot{} - r := &runner{ - bots: []Bot{}, - } +func TestRegisterWatcher(t *testing.T) { + SetupAndRun(func() { + watcher := &DummyWatcher{} + RegisterWatcher(watcher) + r := &runner{} - _ = WithBot(bot)(r) + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } - registeredBots := r.bots - if len(registeredBots) != 1 { - t.Fatalf("One and only one bot should be registered, but actual number was %d.", len(registeredBots)) - } + if r.watcher == nil { + t.Fatal("Watcher is not set") + } - if registeredBots[0] != bot { - t.Fatalf("Passed bot is not registered: %#v.", registeredBots[0]) - } + if r.watcher != watcher { + t.Error("Given Watcher is not set.") + } + }) } -func TestWithCommandProps(t *testing.T) { - var botType BotType = "dummy" - props := &CommandProps{ - botType: botType, - } - r := &runner{ - commandProps: make(map[BotType][]*CommandProps), - } +func TestRegisterWorker(t *testing.T) { + SetupAndRun(func() { + worker := &DummyWorker{} + RegisterWorker(worker) + r := &runner{} - _ = WithCommandProps(props)(r) + for _, v := range options.stashed { + err := v(r) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + } - botCmdProps, ok := r.commandProps[botType] - if !ok { - t.Fatal("Expected BotType is not stashed as key.") - } - if len(botCmdProps) != 1 && botCmdProps[0] != props { - t.Error("Expected CommandProps is not stashed.") - } + if r.worker == nil { + t.Fatal("Worker is not set") + } + if r.worker != worker { + t.Error("Given Worker is not set.") + } + }) } -func TestWithWorker(t *testing.T) { - worker := &DummyWorker{} - r := &runner{} - _ = WithWorker(worker)(r) +func TestRun(t *testing.T) { + SetupAndRun(func() { + config := &Config{ + TimeZone: time.UTC.String(), + } - if r.worker != worker { - t.Fatal("Given worker is not set.") - } + // Initial call with valid setting should work. + err := Run(context.Background(), config) + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } + + err = Run(context.Background(), config) + if err == nil { + t.Fatal("Expected error is not returned.") + } + }) } -func TestWithWatcher(t *testing.T) { - watcher := &DummyWatcher{} - r := &runner{} - _ = WithWatcher(watcher)(r) +func TestRun_WithInvalidConfig(t *testing.T) { + SetupAndRun(func() { + config := &Config{ + TimeZone: "INVALID", + } - if r.watcher != watcher { - t.Fatal("Given watcher is not set.") - } + err := Run(context.Background(), config) + + if err == nil { + t.Error("Expected error is not returned.") + } + }) } -func TestWithScheduledTaskProps(t *testing.T) { - var botType BotType = "dummy" - props := &ScheduledTaskProps{ - botType: botType, - } - r := &runner{ - scheduledTaskPrps: make(map[BotType][]*ScheduledTaskProps), - } +func Test_newRunner(t *testing.T) { + SetupAndRun(func() { + config := &Config{ + TimeZone: time.UTC.String(), + PluginConfigRoot: "/not/empty", + } - _ = WithScheduledTaskProps(props)(r) + r, e := newRunner(context.Background(), config) + if e != nil { + t.Fatalf("Unexpected error is returned: %s.", e.Error()) + } - taskProps, ok := r.scheduledTaskPrps[botType] - if !ok { - t.Fatal("Expected BotType is not stashed as key.") - } - if len(taskProps) != 1 && taskProps[0] != props { - t.Error("Expected ScheduledTaskProps is not stashed.") - } + if r == nil { + t.Fatal("runner instance is not returned.") + } + + if r.watcher == nil { + t.Error("Default Watcher should be set when PluginConfigRoot is not empty.") + } + + if r.scheduler == nil { + t.Error("Scheduler must run at this point.") + } + + if r.worker == nil { + t.Error("Default Worker should be set.") + } + }) } -func TestWithScheduledTask(t *testing.T) { - var botType BotType = "dummy" - task := &DummyScheduledTask{} - r := &runner{ - scheduledTasks: make(map[BotType][]ScheduledTask), - } +func Test_newRunner_WithTimeZoneError(t *testing.T) { + SetupAndRun(func() { + config := &Config{ + TimeZone: "DUMMY", + PluginConfigRoot: "/not/empty", + } + + _, e := newRunner(context.Background(), config) + if e == nil { + t.Fatal("Expected error is not returned.") + } + }) +} - _ = WithScheduledTask(botType, task)(r) +func Test_newRunner_WithOptionError(t *testing.T) { + SetupAndRun(func() { + config := &Config{ + TimeZone: time.UTC.String(), + PluginConfigRoot: "/not/empty", + } + options.stashed = []func(*runner) error{ + func(_ *runner) error { + return errors.New("dummy") + }, + } - tasks, ok := r.scheduledTasks[botType] - if !ok { - t.Fatal("Expected BotType is not stashed as key.") - } - if len(tasks) != 1 && tasks[0] != task { - t.Errorf("Expected task is not stashed: %#v", tasks) - } + _, e := newRunner(context.Background(), config) + if e == nil { + t.Fatal("Expected error is not returned.") + } + }) } -func TestWithAlerter(t *testing.T) { - alerter := &DummyAlerter{} - r := &runner{ - alerters: &alerters{}, - } +func Test_runner_run(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" - _ = WithAlerter(alerter)(r) + bot := &DummyBot{ + BotTypeValue: botType, + RunFunc: func(ctx context.Context, _ func(Input) error, _ func(error)) { + <-ctx.Done() + }, + } - registeredAlerters := r.alerters - if len(*registeredAlerters) != 1 { - t.Fatalf("One and only one alerter should be registered, but actual number was %d.", len(*registeredAlerters)) - } + config := &Config{ + PluginConfigRoot: "", + TimeZone: time.Now().Location().String(), + } + + r := &runner{ + config: config, + bots: []Bot{ + bot, + }, + } + + rootCtx := context.Background() + ctx, cancel := context.WithCancel(rootCtx) + go r.run(ctx) + + time.Sleep(1 * time.Second) + + status := CurrentStatus() + + if len(status.Bots) != 1 { + t.Fatalf("Expected number of Bot is not registered.") + } + + if status.Bots[0].Type != botType { + t.Errorf("Unexpected BotStatus.Type is returned: %s.", status.Bots[0].Type) + } + + if !status.Bots[0].Running { + t.Error("BotStatus.Running should be true at this point.") + } + + cancel() + time.Sleep(1 * time.Second) + + if CurrentStatus().Bots[0].Running { + t.Error("BotStatus.Running should not be true at this point.") + } + }) - if (*registeredAlerters)[0] != alerter { - t.Fatalf("Passed alerter is not registered: %#v.", (*registeredAlerters)[0]) - } } -func TestRunner_Run(t *testing.T) { - var botType BotType = "myBot" +func Test_runner_runBot(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" - // Prepare Bot to be run - passedCommand := make(chan Command, 1) - bot := &DummyBot{ - BotTypeValue: botType, - AppendCommandFunc: func(cmd Command) { - passedCommand <- cmd - }, - RunFunc: func(_ context.Context, _ func(Input) error, _ func(error)) { - return - }, - } + // Prepare Bot to be run + passedCommand := make(chan Command, 1) + bot := &DummyBot{ + BotTypeValue: botType, + AppendCommandFunc: func(cmd Command) { + passedCommand <- cmd + }, + RunFunc: func(_ context.Context, _ func(Input) error, _ func(error)) { + return + }, + } - // Prepare command to be configured on the fly - commandProps := &CommandProps{ - botType: botType, - identifier: "dummy", - matchFunc: func(input Input) bool { - return regexp.MustCompile(`^\.echo`).MatchString(input.Message()) - }, - commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { - return nil, nil - }, - example: ".echo foo", - } + // Prepare command to be configured on the fly + commandProps := &CommandProps{ + botType: botType, + identifier: "dummy", + matchFunc: func(input Input) bool { + return regexp.MustCompile(`^\.echo`).MatchString(input.Message()) + }, + commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { + return nil, nil + }, + example: ".echo foo", + } - // Prepare scheduled task to be configured on the fly - dummySchedule := "@hourly" - dummyTaskConfig := &DummyScheduledTaskConfig{ScheduleValue: dummySchedule} - scheduledTaskProps := &ScheduledTaskProps{ - botType: botType, - identifier: "dummyTask", - taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { - return nil, nil - }, - schedule: dummySchedule, - config: dummyTaskConfig, - defaultDestination: "", - } + // Prepare scheduled task to be configured on the fly + dummySchedule := "@hourly" + dummyTaskConfig := &DummyScheduledTaskConfig{ScheduleValue: dummySchedule} + scheduledTaskProps := &ScheduledTaskProps{ + botType: botType, + identifier: "dummyTask", + taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { + return nil, nil + }, + schedule: dummySchedule, + config: dummyTaskConfig, + defaultDestination: "", + } - // Configure runner - r := &runner{ - config: NewConfig(), - bots: []Bot{bot}, - commandProps: map[BotType][]*CommandProps{ - bot.BotType(): { - commandProps, + // Configure runner + config := &Config{ + PluginConfigRoot: "dummy/config", + TimeZone: time.Now().Location().String(), + } + alerted := make(chan struct{}, 1) + r := &runner{ + config: config, + bots: []Bot{bot}, + commandProps: map[BotType][]*CommandProps{ + bot.BotType(): { + commandProps, + }, }, - }, - scheduledTaskPrps: map[BotType][]*ScheduledTaskProps{ - bot.BotType(): { - scheduledTaskProps, + scheduledTaskProps: map[BotType][]*ScheduledTaskProps{ + bot.BotType(): { + scheduledTaskProps, + }, }, - }, - scheduledTasks: map[BotType][]ScheduledTask{ - bot.BotType(): { - &DummyScheduledTask{}, - &DummyScheduledTask{ScheduleValue: "@every 1m"}, + scheduledTasks: map[BotType][]ScheduledTask{ + bot.BotType(): { + &DummyScheduledTask{}, + &DummyScheduledTask{ScheduleValue: "@every 1m"}, + }, }, - }, - watcher: &DummyWatcher{ - SubscribeFunc: func(_ string, _ string, _ func(string)) error { - return nil + watcher: &DummyWatcher{ + SubscribeFunc: func(_ string, _ string, _ func(string)) error { + return nil + }, + UnsubscribeFunc: func(_ string) error { + return nil + }, }, - UnsubscribeFunc: func(_ string) error { - return nil + worker: &DummyWorker{ + EnqueueFunc: func(fnc func()) error { + return nil + }, }, - }, - worker: &DummyWorker{ - EnqueueFunc: func(fnc func()) error { - return nil + scheduler: &DummyScheduler{ + UpdateFunc: func(_ BotType, _ ScheduledTask, _ func()) error { + return nil + }, + RemoveFunc: func(_ BotType, _ string) error { + return nil + }, + }, + alerters: &alerters{ + &DummyAlerter{ + AlertFunc: func(_ context.Context, _ BotType, err error) error { + alerted <- struct{}{} + return nil + }, + }, }, - }, - status: &status{}, - } - - if r.Status().Running { - t.Error("Status.Running should be false at this point.") - } - - // Let it run - rootCtx := context.Background() - runnerCtx, cancelRunner := context.WithCancel(rootCtx) - finished := make(chan bool) - go func() { - r.Run(runnerCtx) - finished <- true - }() - - time.Sleep(1 * time.Second) - if !r.Status().Running { - t.Error("Status.Running should be true at this point.") - } - if len(r.Status().Bots) != 1 { - t.Error("Status of one Bot must be returned.") - } else { - if !r.Status().Bots[0].Running { - t.Error("Bot's status must be Running.") } - } - cancelRunner() + // Let it run + rootCtx := context.Background() + runnerCtx, cancelRunner := context.WithCancel(rootCtx) + finished := make(chan bool) + go func() { + r.runBot(runnerCtx, bot) + finished <- true + }() + + time.Sleep(1 * time.Second) + cancelRunner() + + select { + case cmd := <-passedCommand: + if cmd == nil || cmd.Identifier() != commandProps.identifier { + t.Errorf("Stashed CommandPropsBuilder was not properly configured: %#v.", passedCommand) + } + + case <-time.NewTimer(10 * time.Second).C: + t.Fatal("CommandPropsBuilder was not properly built.") - select { - case cmd := <-passedCommand: - if cmd == nil || cmd.Identifier() != commandProps.identifier { - t.Errorf("Stashed CommandPropsBuilder was not properly configured: %#v.", passedCommand) } - case <-time.NewTimer(10 * time.Second).C: - t.Fatal("CommandPropsBuilder was not properly built.") + select { + case <-finished: + // O.K. - } + case <-time.NewTimer(10 * time.Second).C: + t.Error("Runner is not finished.") - select { - case <-finished: - // O.K. + } - case <-time.NewTimer(10 * time.Second).C: - t.Error("Runner is not finished.") + if CurrentStatus().Running { + t.Error("Status.Running should be false at this point.") + } - } + select { + case <-alerted: + // O.K. - if r.Status().Running { - t.Error("Status.Running should be false at this point.") - } -} + case <-time.NewTimer(10 * time.Second).C: + t.Error("Alert should be sent no matter how runner is canceled.") -func TestRunner_Run_WithPluginConfigRoot(t *testing.T) { - config := &Config{ - PluginConfigRoot: "dummy/config", - TimeZone: time.Now().Location().String(), - } + } + }) +} - var botType BotType = "bot" - bot := &DummyBot{ - BotTypeValue: botType, - RunFunc: func(_ context.Context, _ func(Input) error, _ func(error)) { - return - }, - } +func Test_runner_runBot_WithPanic(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" - subscribeCh := make(chan struct{}, 2) - r := &runner{ - config: config, - bots: []Bot{bot}, - commandProps: map[BotType][]*CommandProps{}, - scheduledTaskPrps: map[BotType][]*ScheduledTaskProps{}, - scheduledTasks: map[BotType][]ScheduledTask{}, - watcher: &DummyWatcher{ - SubscribeFunc: func(_ string, _ string, _ func(string)) error { - subscribeCh <- struct{}{} - return errors.New("this error should not cause fatal state") + // Prepare Bot to be run + bot := &DummyBot{ + BotTypeValue: botType, + AppendCommandFunc: func(cmd Command) { }, - UnsubscribeFunc: func(_ string) error { - return errors.New("this error also should not cause fatal state") + RunFunc: func(_ context.Context, _ func(Input) error, _ func(error)) { + panic("panic on runner.Run") }, - }, - worker: &DummyWorker{}, - status: &status{}, - } + } - // Let it run - rootCtx := context.Background() - runnerCtx, cancelRunner := context.WithCancel(rootCtx) - go r.Run(runnerCtx) + // Configure runner + config := &Config{ + PluginConfigRoot: "", + TimeZone: time.Now().Location().String(), + } + alerted := make(chan struct{}, 1) + r := &runner{ + config: config, + bots: []Bot{bot}, + alerters: &alerters{ + &DummyAlerter{ + AlertFunc: func(_ context.Context, _ BotType, err error) error { + alerted <- struct{}{} + return nil + }, + }, + }, + } - // Wait till all setup is done. - time.Sleep(100 * time.Millisecond) - cancelRunner() + if CurrentStatus().Running { + t.Error("Status.Running should be false at this point.") + } - select { - case <-subscribeCh: - // O.K. - case <-time.NewTimer(10 * time.Second).C: - t.Error("Watcher.Subscribe is not called.") - } -} + // Let it run + rootCtx := context.Background() + runnerCtx, _ := context.WithCancel(rootCtx) + finished := make(chan bool) + go func() { + r.runBot(runnerCtx, bot) + finished <- true + }() -func TestRunner_Run_Minimal(t *testing.T) { - config := &Config{ - PluginConfigRoot: "/", - TimeZone: time.Now().Location().String(), - } - r := &runner{ - config: config, - bots: []Bot{}, - commandProps: map[BotType][]*CommandProps{}, - scheduledTaskPrps: map[BotType][]*ScheduledTaskProps{}, - scheduledTasks: map[BotType][]ScheduledTask{}, - watcher: nil, - worker: nil, - status: &status{}, - } + time.Sleep(1 * time.Second) - // Let it run - rootCtx := context.Background() - runnerCtx, cancelRunner := context.WithCancel(rootCtx) - defer cancelRunner() + select { + case <-finished: + // O.K. - r.Run(runnerCtx) + case <-time.NewTimer(10 * time.Second).C: + t.Error("Runner is not finished.") - if r.watcher == nil { - t.Error("Default watcher is not set.") - } + } - if r.worker == nil { - t.Error("Default worker is not set.") - } -} + if CurrentStatus().Running { + t.Error("Status.Running should be false at this point.") + } -func TestRunner_Status(t *testing.T) { - r := &runner{status: &status{}} - s := r.Status() + select { + case <-alerted: + // O.K. - if s.Running { - t.Error("Status.Running should be false at this point.") - } + case <-time.NewTimer(10 * time.Second).C: + t.Error("Alert should be sent no matter how runner is canceled.") - if len(s.Bots) != 0 { - t.Error("Status.Bots should be empty at this point.") - } + } + }) } -func Test_runBot(t *testing.T) { - var givenErr error - bot := &DummyBot{ - RunFunc: func(_ context.Context, _ func(Input) error, _ func(error)) { - panic("panic!!!") - }, - } - runBot( - context.TODO(), - bot, - func(_ Input) error { - return nil - }, - func(err error) { - givenErr = err - }, - ) +func Test_runner_subscribeConfigDir(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" + subscribed := make(chan struct { + botTypeStr string + dir string + }) + unsubscribed := make(chan string) + watcher := &DummyWatcher{ + SubscribeFunc: func(botTypeStr string, dir string, _ func(string)) error { + subscribed <- struct { + botTypeStr string + dir string + }{ + botTypeStr: botTypeStr, + dir: dir, + } + return nil + }, + UnsubscribeFunc: func(botTypeStr string) error { + unsubscribed <- botTypeStr + return nil + }, + } - if givenErr == nil { - t.Fatal("Expected error is not returned.") - } + r := &runner{ + watcher: watcher, + } - if _, ok := givenErr.(*BotNonContinuableError); !ok { - t.Errorf("Expected error type is not given: %#v.", givenErr) - } -} + rootCtx := context.Background() + ctx, cancel := context.WithCancel(rootCtx) -func Test_registerCommand(t *testing.T) { - command := &DummyCommand{} - var appendedCommand Command - bot := &DummyBot{AppendCommandFunc: func(cmd Command) { appendedCommand = cmd }} + bot := &DummyBot{ + BotTypeValue: botType, + } - bot.AppendCommand(command) + configDir := "/path/to/config/dir" - if appendedCommand != command { - t.Error("Given Command is not appended.") - } -} + go r.subscribeConfigDir(ctx, bot, configDir) -func Test_registerScheduledTask(t *testing.T) { - called := false - callbackCalled := false - bot := &DummyBot{} - task := &DummyScheduledTask{ - ExecuteFunc: func(_ context.Context) ([]*ScheduledTaskResult, error) { - callbackCalled = true - return nil, nil - }, - } - scheduler := &DummyScheduler{ - UpdateFunc: func(_ BotType, _ ScheduledTask, callback func()) error { - called = true - callback() - return nil - }, - } + select { + case s := <-subscribed: + if s.botTypeStr != botType.String() { + t.Errorf("Unexpected BotType is passed: %s.", s.botTypeStr) + } + if s.dir != configDir { + t.Errorf("Unexpected directory string is passed: %s.", s.dir) + } - registerScheduledTask(context.TODO(), bot, task, scheduler) + case <-time.NewTimer(10 * time.Second).C: + t.Fatal("Subscribing directory data should be passed.") - if called == false { - t.Error("Scheduler's update func is not called.") - } + } - if callbackCalled == false { - t.Error("Callback function is not called.") - } -} + cancel() -func Test_updateCommandConfig(t *testing.T) { - type config struct { - Token string - } - c := &config{ - Token: "default", - } + select { + case u := <-unsubscribed: + if u != botType.String() { + t.Fatalf("Unexpected BotType string is passed: %s.", u) + } + + case <-time.NewTimer(10 * time.Second).C: + t.Fatal("Unsubscribing directory data should be passed.") + + } + }) +} + +func Test_runner_subscribeConfigDir_WithSubscriptionError(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" + subscribed := make(chan struct{}, 1) + watcher := &DummyWatcher{ + SubscribeFunc: func(botTypeStr string, dir string, _ func(string)) error { + subscribed <- struct{}{} + return errors.New("subscription error") + }, + } + + r := &runner{ + watcher: watcher, + } + + rootCtx := context.Background() + ctx, cancel := context.WithCancel(rootCtx) + defer cancel() + + bot := &DummyBot{ + BotTypeValue: botType, + } + + configDir := "/path/to/config/dir" + + // Should not block this time + r.subscribeConfigDir(ctx, bot, configDir) + + select { + case <-subscribed: + // O.K. + + default: + t.Fatal("Watcher.Subscribe should be called.") + + } + }) +} + +func Test_runner_subscribeConfigDir_WithUnsubscriptionError(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" + unsubscribed := make(chan struct{}, 1) + watcher := &DummyWatcher{ + SubscribeFunc: func(botTypeStr string, dir string, _ func(string)) error { + return nil + }, + UnsubscribeFunc: func(_ string) error { + unsubscribed <- struct{}{} + return errors.New("unsubscription error") + + }, + } + + r := &runner{ + watcher: watcher, + } + + rootCtx := context.Background() + ctx, cancel := context.WithCancel(rootCtx) + + bot := &DummyBot{ + BotTypeValue: botType, + } + + configDir := "/path/to/config/dir" + go r.subscribeConfigDir(ctx, bot, configDir) + time.Sleep(500 * time.Millisecond) + cancel() - var botType BotType = "dummy" - var appendCalled bool - bot := &DummyBot{ - BotTypeValue: botType, - AppendCommandFunc: func(_ Command) { - appendCalled = true + select { + case <-unsubscribed: + // O.K. + + case <-time.NewTimer(500 * time.Millisecond).C: + t.Fatal("Watcher.Unsubscribe should be called.") + + } + }) +} + +func Test_runner_subscribeConfigDir_WithCallback(t *testing.T) { + tests := []struct { + isErr bool + path string + }{ + { + isErr: true, + path: "/invalid/file/extension", }, - } - props := []*CommandProps{ { - identifier: "irrelevant", - botType: botType, - commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, - matchFunc: func(_ Input) bool { return true }, - config: nil, - example: "exampleInput", + isErr: true, + path: "/unsupported/file/format.toml", }, { - identifier: "dummy", - botType: botType, - commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, - matchFunc: func(_ Input) bool { return true }, - config: c, - example: "exampleInput", + isErr: false, + path: filepath.Join("testdata", "command", "dummy.json"), + }, + { + isErr: false, + path: filepath.Join("testdata", "command", "dummy.yaml"), }, } - file := &pluginConfigFile{ - id: "dummy", - path: filepath.Join("testdata", "command", "dummy.yaml"), - fileType: yamlFile, + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + SetupAndRun(func() { + var botType BotType = "myBot" + callbackCalled := make(chan struct{}, 1) + watcher := &DummyWatcher{ + SubscribeFunc: func(_ string, _ string, callback func(string)) error { + callback(tt.path) + callbackCalled <- struct{}{} + return nil + }, + UnsubscribeFunc: func(_ string) error { + return nil + }, + } + r := &runner{ + watcher: watcher, + } + bot := &DummyBot{ + BotTypeValue: botType, + } + + rootCtx := context.Background() + ctx, cancel := context.WithCancel(rootCtx) + + go r.subscribeConfigDir(ctx, bot, "dummy") + <-callbackCalled + cancel() + }) + }) } - err := updateCommandConfig(bot, props, file) +} - if err != nil { - t.Errorf("Unexpected error returned: %s.", err.Error()) - } +func Test_registerCommand(t *testing.T) { + SetupAndRun(func() { + command := &DummyCommand{} + var appendedCommand Command + bot := &DummyBot{AppendCommandFunc: func(cmd Command) { appendedCommand = cmd }} - if appendCalled { - t.Error("Bot.AppendCommand should not be called when pointer to CommandConfig is given.") - } + bot.AppendCommand(command) - if c.Token != "foobar" { - t.Errorf("Expected configuration value is not set: %s.", c.Token) - } + if appendedCommand != command { + t.Error("Given Command is not appended.") + } + }) +} + +func Test_registerScheduledTask(t *testing.T) { + SetupAndRun(func() { + called := false + callbackCalled := false + bot := &DummyBot{} + task := &DummyScheduledTask{ + ExecuteFunc: func(_ context.Context) ([]*ScheduledTaskResult, error) { + callbackCalled = true + return nil, nil + }, + } + scheduler := &DummyScheduler{ + UpdateFunc: func(_ BotType, _ ScheduledTask, callback func()) error { + called = true + callback() + return nil + }, + } + + registerScheduledTask(context.TODO(), bot, task, scheduler) + + if called == false { + t.Error("Scheduler's update func is not called.") + } + + if callbackCalled == false { + t.Error("Callback function is not called.") + } + }) +} + +func Test_updateCommandConfig(t *testing.T) { + SetupAndRun(func() { + type config struct { + Token string + } + c := &config{ + Token: "default", + } + + var botType BotType = "dummy" + var appendCalled bool + bot := &DummyBot{ + BotTypeValue: botType, + AppendCommandFunc: func(_ Command) { + appendCalled = true + }, + } + props := []*CommandProps{ + { + identifier: "irrelevant", + botType: botType, + commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, + matchFunc: func(_ Input) bool { return true }, + config: nil, + example: "exampleInput", + }, + { + identifier: "dummy", + botType: botType, + commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, + matchFunc: func(_ Input) bool { return true }, + config: c, + example: "exampleInput", + }, + } + + file := &pluginConfigFile{ + id: "dummy", + path: filepath.Join("testdata", "command", "dummy.yaml"), + fileType: yamlFile, + } + err := updateCommandConfig(bot, props, file) + + if err != nil { + t.Errorf("Unexpected error returned: %s.", err.Error()) + } + + if appendCalled { + t.Error("Bot.AppendCommand should not be called when pointer to CommandConfig is given.") + } + + if c.Token != "foobar" { + t.Errorf("Expected configuration value is not set: %s.", c.Token) + } + }) } func Test_updateCommandConfig_WithBrokenYaml(t *testing.T) { - type config struct { - Token string - } - c := &config{ - Token: "default", - } + SetupAndRun(func() { + type config struct { + Token string + } + c := &config{ + Token: "default", + } - var botType BotType = "dummy" - bot := &DummyBot{ - BotTypeValue: botType, - } - props := []*CommandProps{ - { - identifier: "broken", - botType: botType, - commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, - matchFunc: func(_ Input) bool { return true }, - config: c, - example: "exampleInput", - }, - } + var botType BotType = "dummy" + bot := &DummyBot{ + BotTypeValue: botType, + } + props := []*CommandProps{ + { + identifier: "broken", + botType: botType, + commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, + matchFunc: func(_ Input) bool { return true }, + config: c, + example: "exampleInput", + }, + } - file := &pluginConfigFile{ - id: "broken", - path: filepath.Join("testdata", "command", "broken.yaml"), - fileType: yamlFile, - } - err := updateCommandConfig(bot, props, file) + file := &pluginConfigFile{ + id: "broken", + path: filepath.Join("testdata", "command", "broken.yaml"), + fileType: yamlFile, + } + err := updateCommandConfig(bot, props, file) - if err == nil { - t.Fatal("Expected error is not returned.") - } + if err == nil { + t.Fatal("Expected error is not returned.") + } - if err == errUnableToDetermineConfigFileFormat || err == errUnsupportedConfigFileFormat { - t.Errorf("Unexpected error type was returned: %T", err) - } + if err == errUnableToDetermineConfigFileFormat || err == errUnsupportedConfigFileFormat { + t.Errorf("Unexpected error type was returned: %T", err) + } + }) } func Test_updateCommandConfig_WithConfigValue(t *testing.T) { - type config struct { - Token string - } - c := config{ - Token: "default", - } + SetupAndRun(func() { + type config struct { + Token string + } + c := config{ + Token: "default", + } - var botType BotType = "dummy" - var newCmd Command - bot := &DummyBot{ - BotTypeValue: botType, - AppendCommandFunc: func(cmd Command) { - newCmd = cmd - }, - } - props := []*CommandProps{ - { - identifier: "dummy", - botType: botType, - commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, - matchFunc: func(_ Input) bool { return true }, - config: c, - example: "exampleInput", - }, - } + var botType BotType = "dummy" + var newCmd Command + bot := &DummyBot{ + BotTypeValue: botType, + AppendCommandFunc: func(cmd Command) { + newCmd = cmd + }, + } + props := []*CommandProps{ + { + identifier: "dummy", + botType: botType, + commandFunc: func(_ context.Context, _ Input, _ ...CommandConfig) (*CommandResponse, error) { return nil, nil }, + matchFunc: func(_ Input) bool { return true }, + config: c, + example: "exampleInput", + }, + } - file := &pluginConfigFile{ - id: "dummy", - path: filepath.Join("testdata", "command", "dummy.yaml"), - fileType: yamlFile, - } - err := updateCommandConfig(bot, props, file) + file := &pluginConfigFile{ + id: "dummy", + path: filepath.Join("testdata", "command", "dummy.yaml"), + fileType: yamlFile, + } + err := updateCommandConfig(bot, props, file) - if err != nil { - t.Fatalf("Unexpected error is returned: %s.", err.Error()) - } + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } - if err == errUnableToDetermineConfigFileFormat || err == errUnsupportedConfigFileFormat { - t.Errorf("Unexpected error type was returned: %T.", err) - } + if err == errUnableToDetermineConfigFileFormat || err == errUnsupportedConfigFileFormat { + t.Errorf("Unexpected error type was returned: %T.", err) + } - if newCmd == nil { - t.Error("Bot.AppendCommand must be called to replace old Command when config value is set instead of pointer.") - } + if newCmd == nil { + t.Error("Bot.AppendCommand must be called to replace old Command when config value is set instead of pointer.") + } - v := newCmd.(*defaultCommand).configWrapper.value.(config).Token - if v != "foobar" { - t.Errorf("Newly set config does not reflect value from file: %s.", v) - } + v := newCmd.(*defaultCommand).configWrapper.value.(config).Token + if v != "foobar" { + t.Errorf("Newly set config does not reflect value from file: %s.", v) + } + }) } func Test_updateScheduledTaskConfig(t *testing.T) { - var botType BotType = "dummy" - registeredScheduledTask := 0 - bot := &DummyBot{ - BotTypeValue: botType, - } + SetupAndRun(func() { + var botType BotType = "dummy" + registeredScheduledTask := 0 + bot := &DummyBot{ + BotTypeValue: botType, + } - type config struct { - Token string - } - c := &config{ - Token: "default", - } + type config struct { + Token string + } + c := &config{ + Token: "default", + } - props := []*ScheduledTaskProps{ - { - botType: botType, - identifier: "irrelevant", - taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { return nil, nil }, - schedule: "@every 1m", - defaultDestination: "boo", - config: c, - }, - { - botType: botType, - identifier: "dummy", - taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { return nil, nil }, - schedule: "@every 1m", - defaultDestination: "dummy", - config: c, - }, - } - scheduler := &DummyScheduler{ - UpdateFunc: func(_ BotType, _ ScheduledTask, _ func()) error { - registeredScheduledTask++ - return nil - }, - } + props := []*ScheduledTaskProps{ + { + botType: botType, + identifier: "irrelevant", + taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { return nil, nil }, + schedule: "@every 1m", + defaultDestination: "boo", + config: c, + }, + { + botType: botType, + identifier: "dummy", + taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { return nil, nil }, + schedule: "@every 1m", + defaultDestination: "dummy", + config: c, + }, + } + scheduler := &DummyScheduler{ + UpdateFunc: func(_ BotType, _ ScheduledTask, _ func()) error { + registeredScheduledTask++ + return nil + }, + } - file := &pluginConfigFile{ - id: "dummy", - path: filepath.Join("testdata", "command", "dummy.yaml"), - fileType: yamlFile, - } - err := updateScheduledTaskConfig(context.TODO(), bot, props, scheduler, file) + file := &pluginConfigFile{ + id: "dummy", + path: filepath.Join("testdata", "command", "dummy.yaml"), + fileType: yamlFile, + } + err := updateScheduledTaskConfig(context.TODO(), bot, props, scheduler, file) - if err != nil { - t.Fatalf("Unexpected error is returned: %s.", err.Error()) - } + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } - if registeredScheduledTask != 1 { - t.Errorf("Only one comamnd is expected to be registered: %d.", registeredScheduledTask) - } + if registeredScheduledTask != 1 { + t.Errorf("Only one comamnd is expected to be registered: %d.", registeredScheduledTask) + } + }) } func Test_updateScheduledTaskConfig_WithBrokenYaml(t *testing.T) { - var botType BotType = "dummy" - registeredScheduledTask := 0 - bot := &DummyBot{ - BotTypeValue: botType, - } - props := []*ScheduledTaskProps{ - { - botType: botType, - identifier: "broken", - taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { return nil, nil }, - schedule: "@every 1m", - defaultDestination: "boo", - config: &struct{ Token string }{}, - }, - } + SetupAndRun(func() { + var botType BotType = "dummy" + registeredScheduledTask := 0 + bot := &DummyBot{ + BotTypeValue: botType, + } + props := []*ScheduledTaskProps{ + { + botType: botType, + identifier: "broken", + taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { return nil, nil }, + schedule: "@every 1m", + defaultDestination: "boo", + config: &struct{ Token string }{}, + }, + } - var removeCalled bool - scheduler := &DummyScheduler{ - UpdateFunc: func(_ BotType, _ ScheduledTask, _ func()) error { - registeredScheduledTask++ - return nil - }, - RemoveFunc: func(_ BotType, _ string) error { - removeCalled = true - return nil - }, - } + var removeCalled bool + scheduler := &DummyScheduler{ + UpdateFunc: func(_ BotType, _ ScheduledTask, _ func()) error { + registeredScheduledTask++ + return nil + }, + RemoveFunc: func(_ BotType, _ string) error { + removeCalled = true + return nil + }, + } - file := &pluginConfigFile{ - id: "broken", - path: filepath.Join("testdata", "command", "broken.yaml"), - fileType: yamlFile, - } - err := updateScheduledTaskConfig(context.TODO(), bot, props, scheduler, file) + file := &pluginConfigFile{ + id: "broken", + path: filepath.Join("testdata", "command", "broken.yaml"), + fileType: yamlFile, + } + err := updateScheduledTaskConfig(context.TODO(), bot, props, scheduler, file) - if err == nil { - t.Fatal("Expected error is not returned.") - } + if err == nil { + t.Fatal("Expected error is not returned.") + } - if registeredScheduledTask != 0 { - t.Errorf("No comamnd is expected to be registered: %d.", registeredScheduledTask) - } + if registeredScheduledTask != 0 { + t.Errorf("No comamnd is expected to be registered: %d.", registeredScheduledTask) + } - if !removeCalled { - t.Error("scheduler.remove should be removed when config update fails.") - } + if !removeCalled { + t.Error("scheduler.remove should be removed when config update fails.") + } + }) } func Test_executeScheduledTask(t *testing.T) { - dummyContent := "dummy content" - dummyDestination := "#dummyDestination" - defaultDestination := "#defaultDestination" - type returnVal struct { - results []*ScheduledTaskResult - error error - } - testSets := []struct { - returnVal *returnVal - defaultDestination OutputDestination - }{ - {returnVal: &returnVal{nil, nil}}, - {returnVal: &returnVal{nil, errors.New("dummy")}}, - // Destination is given by neither task result nor configuration, which ends up with early return - {returnVal: &returnVal{[]*ScheduledTaskResult{{Content: dummyContent}}, nil}}, - // Destination is given by configuration - {returnVal: &returnVal{[]*ScheduledTaskResult{{Content: dummyContent}}, nil}, defaultDestination: defaultDestination}, - // Destination is given by task result - {returnVal: &returnVal{[]*ScheduledTaskResult{{Content: dummyContent, Destination: dummyDestination}}, nil}}, - } - - var sendingOutput []Output - dummyBot := &DummyBot{SendMessageFunc: func(_ context.Context, output Output) { - sendingOutput = append(sendingOutput, output) - }} + SetupAndRun(func() { + dummyContent := "dummy content" + dummyDestination := "#dummyDestination" + defaultDestination := "#defaultDestination" + type returnVal struct { + results []*ScheduledTaskResult + error error + } + testSets := []struct { + returnVal *returnVal + defaultDestination OutputDestination + }{ + {returnVal: &returnVal{nil, nil}}, + {returnVal: &returnVal{nil, errors.New("dummy")}}, + // Destination is given by neither task result nor configuration, which ends up with early return + {returnVal: &returnVal{[]*ScheduledTaskResult{{Content: dummyContent}}, nil}}, + // Destination is given by configuration + {returnVal: &returnVal{[]*ScheduledTaskResult{{Content: dummyContent}}, nil}, defaultDestination: defaultDestination}, + // Destination is given by task result + {returnVal: &returnVal{[]*ScheduledTaskResult{{Content: dummyContent, Destination: dummyDestination}}, nil}}, + } - for _, testSet := range testSets { - task := &scheduledTask{ - identifier: "dummy", - taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { - val := testSet.returnVal - return val.results, val.error - }, - defaultDestination: testSet.defaultDestination, - configWrapper: &taskConfigWrapper{ - value: &DummyScheduledTaskConfig{}, - mutex: &sync.RWMutex{}, - }, + var sendingOutput []Output + dummyBot := &DummyBot{SendMessageFunc: func(_ context.Context, output Output) { + sendingOutput = append(sendingOutput, output) + }} + + for _, testSet := range testSets { + task := &scheduledTask{ + identifier: "dummy", + taskFunc: func(_ context.Context, _ ...TaskConfig) ([]*ScheduledTaskResult, error) { + val := testSet.returnVal + return val.results, val.error + }, + defaultDestination: testSet.defaultDestination, + configWrapper: &taskConfigWrapper{ + value: &DummyScheduledTaskConfig{}, + mutex: &sync.RWMutex{}, + }, + } + executeScheduledTask(context.TODO(), dummyBot, task) } - executeScheduledTask(context.TODO(), dummyBot, task) - } - if len(sendingOutput) != 2 { - t.Fatalf("Expecting sending method to be called twice, but was called %d time(s).", len(sendingOutput)) - } - if sendingOutput[0].Content() != dummyContent || sendingOutput[0].Destination() != defaultDestination { - t.Errorf("Sending output differs from expecting one: %#v.", sendingOutput) - } - if sendingOutput[1].Content() != dummyContent || sendingOutput[1].Destination() != dummyDestination { - t.Errorf("Sending output differs from expecting one: %#v.", sendingOutput) - } + if len(sendingOutput) != 2 { + t.Fatalf("Expecting sending method to be called twice, but was called %d time(s).", len(sendingOutput)) + } + if sendingOutput[0].Content() != dummyContent || sendingOutput[0].Destination() != defaultDestination { + t.Errorf("Sending output differs from expecting one: %#v.", sendingOutput) + } + if sendingOutput[1].Content() != dummyContent || sendingOutput[1].Destination() != dummyDestination { + t.Errorf("Sending output differs from expecting one: %#v.", sendingOutput) + } + }) } func Test_superviseBot(t *testing.T) { - rootCxt := context.Background() - alerted := make(chan bool) - alerters := &alerters{ - &DummyAlerter{ - AlertFunc: func(_ context.Context, _ BotType, err error) error { - panic("Panic should not affect other alerters' behavior.") + SetupAndRun(func() { + rootCxt := context.Background() + alerted := make(chan bool) + alerters := &alerters{ + &DummyAlerter{ + AlertFunc: func(_ context.Context, _ BotType, err error) error { + panic("Panic should not affect other alerters' behavior.") + }, }, - }, - &DummyAlerter{ - AlertFunc: func(_ context.Context, _ BotType, err error) error { - alerted <- true - return nil + &DummyAlerter{ + AlertFunc: func(_ context.Context, _ BotType, err error) error { + alerted <- true + return nil + }, }, - }, - } - botCtx, errSupervisor := superviseBot(rootCxt, "DummyBotType", alerters) + } + botCtx, errSupervisor := superviseBot(rootCxt, "DummyBotType", alerters) - select { - case <-botCtx.Done(): - t.Error("Bot context should not be canceled at this point.") - default: - // O.K. - } + select { + case <-botCtx.Done(): + t.Error("Bot context should not be canceled at this point.") + default: + // O.K. + } - errSupervisor(NewBotNonContinuableError("should stop")) + errSupervisor(NewBotNonContinuableError("should stop")) - select { - case <-botCtx.Done(): - // O.K. - case <-time.NewTimer(10 * time.Second).C: - t.Error("Bot context should be canceled at this point.") - } - if e := botCtx.Err(); e != context.Canceled { - t.Errorf("botCtx.Err() must return context.Canceled, but was %#v", e) - } - select { - case <-alerted: - // O.K. - case <-time.NewTimer(10 * time.Second).C: - t.Error("Alert should be sent at this point.") - } + select { + case <-botCtx.Done(): + // O.K. + case <-time.NewTimer(10 * time.Second).C: + t.Error("Bot context should be canceled at this point.") + } + if e := botCtx.Err(); e != context.Canceled { + t.Errorf("botCtx.Err() must return context.Canceled, but was %#v", e) + } + select { + case <-alerted: + // O.K. + case <-time.NewTimer(10 * time.Second).C: + t.Error("Alert should be sent at this point.") + } - nonBlocking := make(chan bool) - go func() { - errSupervisor(NewBotNonContinuableError("call after context cancellation should not block")) - nonBlocking <- true - }() - select { - case <-nonBlocking: - // O.K. - case <-time.NewTimer(10 * time.Second).C: - t.Error("Call after context cancellation blocks.") - } + nonBlocking := make(chan bool) + go func() { + errSupervisor(NewBotNonContinuableError("call after context cancellation should not block")) + nonBlocking <- true + }() + select { + case <-nonBlocking: + // O.K. + case <-time.NewTimer(10 * time.Second).C: + t.Error("Call after context cancellation blocks.") + } + }) } func Test_setupInputReceiver(t *testing.T) { - responded := make(chan bool, 1) - worker := &DummyWorker{ - EnqueueFunc: func(fnc func()) error { - fnc() - return nil - }, - } + SetupAndRun(func() { + responded := make(chan bool, 1) + worker := &DummyWorker{ + EnqueueFunc: func(fnc func()) error { + fnc() + return nil + }, + } - bot := &DummyBot{ - BotTypeValue: "DUMMY", - RespondFunc: func(_ context.Context, input Input) error { - responded <- true - return errors.New("error is returned, but still doesn't block") - }, - } + bot := &DummyBot{ + BotTypeValue: "DUMMY", + RespondFunc: func(_ context.Context, input Input) error { + responded <- true + return errors.New("error is returned, but still doesn't block") + }, + } - receiveInput := setupInputReceiver(context.TODO(), bot, worker) - if err := receiveInput(&DummyInput{}); err != nil { - t.Errorf("Error should not be returned at this point: %s.", err.Error()) - } + receiveInput := setupInputReceiver(context.TODO(), bot, worker) + if err := receiveInput(&DummyInput{}); err != nil { + t.Errorf("Error should not be returned at this point: %s.", err.Error()) + } - select { - case <-responded: - // O.K. - case <-time.NewTimer(10 * time.Second).C: - t.Error("Received input was not processed.") - } + select { + case <-responded: + // O.K. + case <-time.NewTimer(10 * time.Second).C: + t.Error("Received input was not processed.") + } + }) } func Test_setupInputReceiver_BlockedInputError(t *testing.T) { - bot := &DummyBot{} - worker := &DummyWorker{ - EnqueueFunc: func(fnc func()) error { - return errors.New("any error should result in BlockedInputError") - }, - } + SetupAndRun(func() { + bot := &DummyBot{} + worker := &DummyWorker{ + EnqueueFunc: func(fnc func()) error { + return errors.New("any error should result in BlockedInputError") + }, + } - receiveInput := setupInputReceiver(context.TODO(), bot, worker) - err := receiveInput(&DummyInput{}) - if err == nil { - t.Fatal("Expected error is not returned.") - } + receiveInput := setupInputReceiver(context.TODO(), bot, worker) + err := receiveInput(&DummyInput{}) + if err == nil { + t.Fatal("Expected error is not returned.") + } - if _, ok := err.(*BlockedInputError); !ok { - t.Fatalf("Expected error type is not returned: %T.", err) - } + if _, ok := err.(*BlockedInputError); !ok { + t.Fatalf("Expected error type is not returned: %T.", err) + } + }) } func Test_plainPathToFile(t *testing.T) { - tests := []struct { - input string - id string - path string - fileType fileType - err error - }{ - { - input: "./foo/bar.yaml", - id: "bar", - path: func() string { p, _ := filepath.Abs("./foo/bar.yaml"); return p }(), - fileType: yamlFile, - }, - { - input: "/abs/foo/bar.yml", - id: "bar", - path: func() string { p, _ := filepath.Abs("/abs/foo/bar.yml"); return p }(), - fileType: yamlFile, - }, - { - input: "foo/bar.json", - id: "bar", - path: func() string { p, _ := filepath.Abs("foo/bar.json"); return p }(), - fileType: jsonFile, - }, - { - input: "/abs/foo/undetermined", - err: errUnableToDetermineConfigFileFormat, - }, - { - input: "/abs/foo/unsupported.csv", - err: errUnsupportedConfigFileFormat, - }, - } + SetupAndRun(func() { + tests := []struct { + input string + id string + path string + fileType fileType + err error + }{ + { + input: "./foo/bar.yaml", + id: "bar", + path: func() string { p, _ := filepath.Abs("./foo/bar.yaml"); return p }(), + fileType: yamlFile, + }, + { + input: "/abs/foo/bar.yml", + id: "bar", + path: func() string { p, _ := filepath.Abs("/abs/foo/bar.yml"); return p }(), + fileType: yamlFile, + }, + { + input: "foo/bar.json", + id: "bar", + path: func() string { p, _ := filepath.Abs("foo/bar.json"); return p }(), + fileType: jsonFile, + }, + { + input: "/abs/foo/undetermined", + err: errUnableToDetermineConfigFileFormat, + }, + { + input: "/abs/foo/unsupported.csv", + err: errUnsupportedConfigFileFormat, + }, + } - for i, test := range tests { - testNo := i + 1 - file, err := plainPathToFile(test.input) + for i, test := range tests { + testNo := i + 1 + file, err := plainPathToFile(test.input) - if test.err == nil { - if err != nil { - t.Errorf("Unexpected error is retuend on test %d: %s.", testNo, err.Error()) - continue - } + if test.err == nil { + if err != nil { + t.Errorf("Unexpected error is retuend on test %d: %s.", testNo, err.Error()) + continue + } - if file.id != test.id { - t.Errorf("Unexpected id is set on test %d: %s.", testNo, file.id) - } + if file.id != test.id { + t.Errorf("Unexpected id is set on test %d: %s.", testNo, file.id) + } - if file.path != test.path { - t.Errorf("Unexpected path is set on test %d: %s.", testNo, file.path) - } + if file.path != test.path { + t.Errorf("Unexpected path is set on test %d: %s.", testNo, file.path) + } - if file.fileType != test.fileType { - t.Errorf("Unexpected fileType is set on test %d: %d.", testNo, file.fileType) - } + if file.fileType != test.fileType { + t.Errorf("Unexpected fileType is set on test %d: %d.", testNo, file.fileType) + } - continue - } + continue + } - if err != test.err { - t.Errorf("Unexpected error is returned: %#v.", err) + if err != test.err { + t.Errorf("Unexpected error is returned: %#v.", err) + } } - } + }) } func Test_findPluginConfigFile(t *testing.T) { - tests := []struct { - configDir string - id string - found bool - }{ - { - configDir: filepath.Join("testdata", "command"), - id: "dummy", - found: true, - }, - { - configDir: filepath.Join("testdata", "nonExistingDir"), - id: "notFound", - found: false, - }, - } + SetupAndRun(func() { + tests := []struct { + configDir string + id string + found bool + }{ + { + configDir: filepath.Join("testdata", "command"), + id: "dummy", + found: true, + }, + { + configDir: filepath.Join("testdata", "nonExistingDir"), + id: "notFound", + found: false, + }, + } - for i, test := range tests { - testNo := i + 1 - file := findPluginConfigFile(test.configDir, test.id) - if test.found && file == nil { - t.Error("Expected *pluginConfigFile is not returned.") - } else if !test.found && file != nil { - t.Errorf("Unexpected returned value on test %d: %#v.", testNo, file) + for i, test := range tests { + testNo := i + 1 + file := findPluginConfigFile(test.configDir, test.id) + if test.found && file == nil { + t.Error("Expected *pluginConfigFile is not returned.") + } else if !test.found && file != nil { + t.Errorf("Unexpected returned value on test %d: %#v.", testNo, file) + } } - } + }) } diff --git a/slack/adapter.go b/slack/adapter.go index 72832f1..3de0af1 100644 --- a/slack/adapter.go +++ b/slack/adapter.go @@ -89,18 +89,15 @@ func WithPayloadHandler(fnc func(context.Context, *Config, rtmapi.DecodedPayload // Adapter internally calls Slack Rest API and Real Time Messaging API to offer Bot developers easy way to communicate with Slack. // -// This implements sarah.Adapter interface, so this instance can be fed to sarah.Runner as below. -// -// runnerOptions := sarah.NewRunnerOptions() +// This implements sarah.Adapter interface, so this instance can be fed to sarah.RegisterBot() as below. // // slackConfig := slack.NewConfig() // slackConfig.Token = "XXXXXXXXXXXX" // Set token manually or feed slackConfig to json.Unmarshal or yaml.Unmarshal // slackAdapter, _ := slack.NewAdapter(slackConfig) // slackBot, _ := sarah.NewBot(slackAdapter) -// runnerOptions.Append(sarah.WithBot(slackBot)) +// sarah.RegisterBot(slackBot) // -// runner := sarah.NewRunner(sarah.NewConfig(), runnerOptions.Arg()) -// runner.Run(context.TODO()) +// sarah.Run(context.TODO(), sarah.NewConfig()) type Adapter struct { config *Config client SlackClient @@ -150,11 +147,11 @@ func (adapter *Adapter) BotType() sarah.BotType { // Run establishes connection with Slack, supervise it, and tries to reconnect when current connection is gone. // Connection will be // -// When message is sent from slack server, the payload is passed to sarah.Runner via the function given as 2nd argument, enqueueInput. +// When message is sent from slack server, the payload is passed to go-sarah's core via the function given as 2nd argument, enqueueInput. // This function simply wraps a channel to prevent blocking situation. When workers are too busy and channel blocks, this function returns BlockedInputError. // -// When critical situation such as reconnection trial fails for specified times, this critical situation is notified to sarah.Runner via 3rd argument function, notifyErr. -// sarah.Runner cancels this Bot/Adapter and related resources when BotNonContinuableError is given to this function. +// When critical situation such as reconnection trial fails for specified times, this critical situation is notified to go-sarah's core via 3rd argument function, notifyErr. +// go-sarah cancels this Bot/Adapter and related resources when BotNonContinuableError is given to this function. func (adapter *Adapter) Run(ctx context.Context, enqueueInput func(sarah.Input) error, notifyErr func(error)) { for { conn, err := adapter.connect(ctx) diff --git a/status.go b/status.go index 06f2d6f..978e6bf 100644 --- a/status.go +++ b/status.go @@ -1,10 +1,19 @@ package sarah import ( + "errors" "github.com/oklahomer/go-sarah/log" "sync" ) +var runnerStatus = &status{} + +var ErrRunnerAlreadyRunning = errors.New("go-sarah's process is already running") + +func CurrentStatus() Status { + return runnerStatus.snapshot() +} + // Status represents the current status of the bot system including Runner and all registered Bots. type Status struct { Running bool @@ -44,11 +53,16 @@ func (s *status) running() bool { } } -func (s *status) start() { +func (s *status) start() error { s.mutex.Lock() defer s.mutex.Unlock() + if s.finished != nil { + return ErrRunnerAlreadyRunning + } + s.finished = make(chan struct{}) + return nil } func (s *status) addBot(bot Bot) { @@ -77,18 +91,18 @@ func (s *status) snapshot() Status { s.mutex.RLock() defer s.mutex.RUnlock() - snapshot := Status{ - Running: s.running(), - } + var bots []BotStatus for _, botStatus := range s.bots { bs := BotStatus{ Type: botStatus.botType, Running: botStatus.running(), } - snapshot.Bots = append(snapshot.Bots, bs) + bots = append(bots, bs) + } + return Status{ + Running: s.running(), + Bots: bots, } - - return snapshot } func (s *status) stop() { diff --git a/status_test.go b/status_test.go index f8b2b40..7b53115 100644 --- a/status_test.go +++ b/status_test.go @@ -5,13 +5,74 @@ import ( "time" ) +func TestCurrentStatus(t *testing.T) { + // Override the package scoped variable that holds *status instance. + // Copy of this status should be returned on CurrentStatus(). + botType := BotType("dummy") + runnerStatus = &status{ + bots: []*botStatus{ + { + botType: botType, + finished: make(chan struct{}), + }, + }, + } + + // Check initial state + currentStatus := CurrentStatus() + + if currentStatus.Running { + t.Error("Status should not be Running at this point.") + } + + if len(currentStatus.Bots) != 1 { + t.Fatalf("Unexpected number of BotStatus is returned: %d.", len(currentStatus.Bots)) + } + + if currentStatus.Bots[0].Type != botType { + t.Errorf("Expected BotType is not set. %#v", currentStatus.Bots[0]) + } +} + func Test_status_start(t *testing.T) { s := &status{} - s.start() + + // Initial call + err := s.start() + if err != nil { + t.Fatalf("Unexpected error is returned: %s.", err.Error()) + } if s.finished == nil { t.Error("A channel to judge running status must be set.") } + + // Successive call should return an error + err = s.start() + if err == nil { + t.Fatalf("Expected error is not returned.") + } + if err != ErrRunnerAlreadyRunning { + t.Errorf("Returned error is not the expected one: %s", err.Error()) + } +} + +func Test_status_running(t *testing.T) { + s := &status{} + + if s.running() { + t.Error("Status should not be Running at this point.") + } + + s.finished = make(chan struct{}) + if !s.running() { + t.Error("Status should be Running at this point.") + } + + close(s.finished) + if s.running() { + t.Error("Status should not be Running at this point.") + } } func Test_status_stop(t *testing.T) { diff --git a/workers/worker.go b/workers/worker.go index 8f5f8a4..b4512e4 100644 --- a/workers/worker.go +++ b/workers/worker.go @@ -82,12 +82,11 @@ func (w *worker) Enqueue(fnc func()) error { } // Worker is an interface that all Worker implementation must satisfy. -// Worker implementation can be fed to sarah.Runner via sarah.RunnerOption as below. +// Worker implementation can be fed to sarah.RegisterWorker() to replace default implementation as below. +// Given worker is used on sarah.Run() call. // // myWorker := NewMyWorkerImpl() -// option := sarah.WithWorker(myWorker) -// -// runner, _ := sarah.NewRunner(sarah.NewConfig(), option) +// sarah.RegisterWorker(myWorker) type Worker interface { Enqueue(func()) error }