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:
google-labs-jules[bot] 2025-11-23 19:27:52 +00:00 committed by GitHub
parent 44dd023c43
commit e4a77cb1ae
5 changed files with 279 additions and 8 deletions

49
core.go
View file

@ -30,12 +30,7 @@ func New(opts ...Option) (*Core, error) {
return nil, err return nil, err
} }
} }
c.once.Do(func() {
c.initErr = nil
})
if c.initErr != nil {
return nil, c.initErr
}
if c.serviceLock { if c.serviceLock {
c.servicesLocked = true c.servicesLocked = true
} }
@ -138,13 +133,43 @@ func WithServiceLock() Option {
// ServiceStartup is the entry point for the Core service's startup lifecycle. // ServiceStartup is the entry point for the Core service's startup lifecycle.
// It is called by Wails when the application starts. // It is called by Wails when the application starts.
func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { 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. // ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
// It is called by Wails when the application shuts down. // It is called by Wails when the application shuts down.
func (c *Core) ServiceShutdown(ctx context.Context) error { 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. // 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) return fmt.Errorf("core: service %q already registered", name)
} }
c.services[name] = api 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 return nil
} }

43
core_extra_test.go Normal file
View 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
View 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")
}

View file

@ -1,6 +1,7 @@
package core package core
import ( import (
"context"
"embed" "embed"
"io" "io"
"sync" "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. // Any struct can be a message, allowing for structured data to be passed between services.
type Message interface{} 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. // Core is the central application object that manages services, assets, and communication.
type Core struct { type Core struct {
once sync.Once once sync.Once
@ -59,6 +70,8 @@ type Core struct {
serviceMu sync.RWMutex serviceMu sync.RWMutex
services map[string]any services map[string]any
servicesLocked bool servicesLocked bool
startables []Startable
stoppables []Stoppable
} }
var instance *Core var instance *Core

18
runtime_pkg_extra_test.go Normal file
View 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")
}