Implement lifecycle interfaces for Core services (#33)
* Implement Startable and Stoppable lifecycle interfaces * Refactor tests: remove redundant method overrides in MockLifecycle --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
parent
44dd023c43
commit
e4a77cb1ae
5 changed files with 279 additions and 8 deletions
49
core.go
49
core.go
|
|
@ -30,12 +30,7 @@ func New(opts ...Option) (*Core, error) {
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
c.once.Do(func() {
|
||||
c.initErr = nil
|
||||
})
|
||||
if c.initErr != nil {
|
||||
return nil, c.initErr
|
||||
}
|
||||
|
||||
if c.serviceLock {
|
||||
c.servicesLocked = true
|
||||
}
|
||||
|
|
@ -138,13 +133,43 @@ func WithServiceLock() Option {
|
|||
// ServiceStartup is the entry point for the Core service's startup lifecycle.
|
||||
// It is called by Wails when the application starts.
|
||||
func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
return c.ACTION(ActionServiceStartup{})
|
||||
c.serviceMu.RLock()
|
||||
startables := append([]Startable(nil), c.startables...)
|
||||
c.serviceMu.RUnlock()
|
||||
|
||||
var agg error
|
||||
for _, s := range startables {
|
||||
if err := s.OnStartup(ctx); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.ACTION(ActionServiceStartup{}); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
|
||||
return agg
|
||||
}
|
||||
|
||||
// ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
|
||||
// It is called by Wails when the application shuts down.
|
||||
func (c *Core) ServiceShutdown(ctx context.Context) error {
|
||||
return c.ACTION(ActionServiceShutdown{})
|
||||
var agg error
|
||||
if err := c.ACTION(ActionServiceShutdown{}); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
|
||||
c.serviceMu.RLock()
|
||||
stoppables := append([]Stoppable(nil), c.stoppables...)
|
||||
c.serviceMu.RUnlock()
|
||||
|
||||
for i := len(stoppables) - 1; i >= 0; i-- {
|
||||
if err := stoppables[i].OnShutdown(ctx); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
}
|
||||
|
||||
return agg
|
||||
}
|
||||
|
||||
// ACTION dispatches a message to all registered IPC handlers.
|
||||
|
|
@ -191,6 +216,14 @@ func (c *Core) RegisterService(name string, api any) error {
|
|||
return fmt.Errorf("core: service %q already registered", name)
|
||||
}
|
||||
c.services[name] = api
|
||||
|
||||
if s, ok := api.(Startable); ok {
|
||||
c.startables = append(c.startables, s)
|
||||
}
|
||||
if s, ok := api.(Stoppable); ok {
|
||||
c.stoppables = append(c.stoppables, s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
43
core_extra_test.go
Normal file
43
core_extra_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MockServiceWithIPC struct {
|
||||
MockService
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error {
|
||||
m.handled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_WithService_IPC(t *testing.T) {
|
||||
svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}}
|
||||
factory := func(c *Core) (any, error) {
|
||||
return svc, nil
|
||||
}
|
||||
c, err := New(WithService(factory))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Trigger ACTION to verify handler was registered
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, svc.handled)
|
||||
}
|
||||
|
||||
func TestCore_ACTION_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
errHandler := func(c *Core, msg Message) error {
|
||||
return assert.AnError
|
||||
}
|
||||
c.RegisterAction(errHandler)
|
||||
err = c.ACTION(nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), assert.AnError.Error())
|
||||
}
|
||||
164
core_lifecycle_test.go
Normal file
164
core_lifecycle_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
type MockStartable struct {
|
||||
started bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStartable) OnStartup(ctx context.Context) error {
|
||||
m.started = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
type MockStoppable struct {
|
||||
stopped bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
|
||||
m.stopped = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
type MockLifecycle struct {
|
||||
MockStartable
|
||||
MockStoppable
|
||||
}
|
||||
|
||||
func TestCore_LifecycleInterfaces(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
startable := &MockStartable{}
|
||||
stoppable := &MockStoppable{}
|
||||
lifecycle := &MockLifecycle{}
|
||||
|
||||
// Register services
|
||||
err = c.RegisterService("startable", startable)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("stoppable", stoppable)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("lifecycle", lifecycle)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, startable.started)
|
||||
assert.True(t, lifecycle.started)
|
||||
assert.False(t, stoppable.stopped)
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, stoppable.stopped)
|
||||
assert.True(t, lifecycle.stopped)
|
||||
}
|
||||
|
||||
type MockLifecycleWithLog struct {
|
||||
id string
|
||||
log *[]string
|
||||
}
|
||||
|
||||
func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error {
|
||||
*m.log = append(*m.log, "start-"+m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error {
|
||||
*m.log = append(*m.log, "stop-"+m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_LifecycleOrder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
var callOrder []string
|
||||
|
||||
s1 := &MockLifecycleWithLog{id: "1", log: &callOrder}
|
||||
s2 := &MockLifecycleWithLog{id: "2", log: &callOrder}
|
||||
|
||||
err = c.RegisterService("s1", s1)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("s2", s2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
|
||||
|
||||
// Reset log
|
||||
callOrder = nil
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder)
|
||||
}
|
||||
|
||||
func TestCore_LifecycleErrors(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
s1 := &MockStartable{err: assert.AnError}
|
||||
s2 := &MockStoppable{err: assert.AnError}
|
||||
|
||||
c.RegisterService("s1", s1)
|
||||
c.RegisterService("s2", s2)
|
||||
|
||||
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestCore_LifecycleErrors_Aggregated(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Register action that fails
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if _, ok := msg.(ActionServiceStartup); ok {
|
||||
return errors.New("startup action error")
|
||||
}
|
||||
if _, ok := msg.(ActionServiceShutdown); ok {
|
||||
return errors.New("shutdown action error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register service that fails
|
||||
s1 := &MockStartable{err: errors.New("startup service error")}
|
||||
s2 := &MockStoppable{err: errors.New("shutdown service error")}
|
||||
|
||||
err = c.RegisterService("s1", s1)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("s2", s2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup action error")
|
||||
assert.Contains(t, err.Error(), "startup service error")
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "shutdown action error")
|
||||
assert.Contains(t, err.Error(), "shutdown service error")
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io"
|
||||
"sync"
|
||||
|
|
@ -46,6 +47,16 @@ type Option func(*Core) error
|
|||
// Any struct can be a message, allowing for structured data to be passed between services.
|
||||
type Message interface{}
|
||||
|
||||
// Startable is an interface for services that need to perform initialization.
|
||||
type Startable interface {
|
||||
OnStartup(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Stoppable is an interface for services that need to perform cleanup.
|
||||
type Stoppable interface {
|
||||
OnShutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Core is the central application object that manages services, assets, and communication.
|
||||
type Core struct {
|
||||
once sync.Once
|
||||
|
|
@ -59,6 +70,8 @@ type Core struct {
|
|||
serviceMu sync.RWMutex
|
||||
services map[string]any
|
||||
servicesLocked bool
|
||||
startables []Startable
|
||||
stoppables []Stoppable
|
||||
}
|
||||
|
||||
var instance *Core
|
||||
|
|
|
|||
18
runtime_pkg_extra_test.go
Normal file
18
runtime_pkg_extra_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewWithFactories_EmptyName(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"": func() (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
},
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "service name cannot be empty")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue