mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
78fd080189
Add a non-blocking runtime publish path and switch hot-path publishers to it. Enforce subscription timeout boundaries, keep ordered subscriber snapshots up to date on subscribe changes, expose all runtime kinds to process hooks, add safe log attrs for non-agent events, and close the gateway message bus on full shutdown.
212 lines
5.8 KiB
Go
212 lines
5.8 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/agent"
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
|
)
|
|
|
|
func TestRun_StartupFailuresReturnErrorAndEmitStructuredLog(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
prepare func(t *testing.T, dir string) string
|
|
wantErr string
|
|
wantLogSub string
|
|
}{
|
|
{
|
|
name: "invalid config returns load error",
|
|
prepare: func(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
cfgPath := filepath.Join(dir, "invalid-config.json")
|
|
if err := os.WriteFile(cfgPath, []byte("{invalid-json"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(invalid config) error = %v", err)
|
|
}
|
|
return cfgPath
|
|
},
|
|
wantErr: "error loading config:",
|
|
wantLogSub: "error loading config:",
|
|
},
|
|
{
|
|
name: "invalid config returns pre-check error",
|
|
prepare: func(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Port = 0
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
if err := config.SaveConfig(cfgPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
return cfgPath
|
|
},
|
|
wantErr: "config pre-check failed: invalid gateway port: 0",
|
|
wantLogSub: "config pre-check failed: invalid gateway port: 0",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
homeDir := t.TempDir()
|
|
configPath := tt.prepare(t, homeDir)
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestGatewayRunStartupFailureHelper")
|
|
cmd.Env = append(os.Environ(),
|
|
"GO_WANT_GATEWAY_RUN_HELPER=1",
|
|
"PICO_TEST_HOME="+homeDir,
|
|
"PICO_TEST_CONFIG="+configPath,
|
|
)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("helper exited unexpectedly: %v\noutput:\n%s", err, string(output))
|
|
}
|
|
|
|
out := string(output)
|
|
if !strings.Contains(out, tt.wantErr) {
|
|
t.Fatalf("helper output missing expected error substring %q:\n%s", tt.wantErr, out)
|
|
}
|
|
|
|
logData, readErr := os.ReadFile(filepath.Join(homeDir, logPath, logFile))
|
|
if readErr != nil {
|
|
t.Fatalf("ReadFile(gateway.log) error = %v", readErr)
|
|
}
|
|
logText := string(logData)
|
|
if !strings.Contains(logText, "Gateway startup failed") {
|
|
t.Fatalf("gateway.log missing structured startup failure log:\n%s", logText)
|
|
}
|
|
if !strings.Contains(logText, tt.wantLogSub) {
|
|
t.Fatalf("gateway.log missing expected failure detail %q:\n%s", tt.wantLogSub, logText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGatewayRunStartupFailureHelper(t *testing.T) {
|
|
if os.Getenv("GO_WANT_GATEWAY_RUN_HELPER") != "1" {
|
|
return
|
|
}
|
|
|
|
homeDir := os.Getenv("PICO_TEST_HOME")
|
|
configPath := os.Getenv("PICO_TEST_CONFIG")
|
|
|
|
err := Run(false, homeDir, configPath, false)
|
|
if err == nil {
|
|
fmt.Fprintln(os.Stdout, "expected startup error, got nil")
|
|
os.Exit(2)
|
|
}
|
|
|
|
fmt.Fprintln(os.Stdout, err.Error())
|
|
os.Exit(0)
|
|
}
|
|
|
|
func TestPublishGatewayEvent(t *testing.T) {
|
|
eventBus := runtimeevents.NewBus()
|
|
t.Cleanup(func() {
|
|
if err := eventBus.Close(); err != nil {
|
|
t.Fatalf("Close runtime event bus: %v", err)
|
|
}
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
sub, eventsCh, err := eventBus.Channel().OfKind(runtimeevents.KindGatewayStart).SubscribeChan(
|
|
ctx,
|
|
runtimeevents.SubscribeOptions{Name: "gateway-test", Buffer: 4},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SubscribeChan() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sub.Close(); err != nil {
|
|
t.Fatalf("Close subscription: %v", err)
|
|
}
|
|
})
|
|
|
|
al := agent.NewAgentLoop(
|
|
config.DefaultConfig(),
|
|
bus.NewMessageBus(),
|
|
&startupBlockedProvider{reason: "not used"},
|
|
agent.WithRuntimeEvents(eventBus),
|
|
)
|
|
t.Cleanup(al.Close)
|
|
|
|
startedAt := time.Now().Add(-1500 * time.Millisecond)
|
|
publishGatewayEvent(al, runtimeevents.KindGatewayStart, startedAt, nil)
|
|
|
|
evt := receiveGatewayRuntimeEvent(t, eventsCh)
|
|
if evt.Kind != runtimeevents.KindGatewayStart ||
|
|
evt.Source.Component != "gateway" ||
|
|
evt.Severity != runtimeevents.SeverityInfo {
|
|
t.Fatalf("gateway event = %+v", evt)
|
|
}
|
|
payload, ok := evt.Payload.(gatewayEventPayload)
|
|
if !ok {
|
|
t.Fatalf("payload type = %T, want gatewayEventPayload", evt.Payload)
|
|
}
|
|
if payload.DurationMS <= 0 {
|
|
t.Fatalf("DurationMS = %d, want positive", payload.DurationMS)
|
|
}
|
|
if evt.Attrs["duration_ms"] == nil {
|
|
t.Fatalf("gateway event attrs missing duration_ms: %#v", evt.Attrs)
|
|
}
|
|
}
|
|
|
|
func TestShutdownGatewayClosesMessageBus(t *testing.T) {
|
|
msgBus := bus.NewMessageBus()
|
|
al := agent.NewAgentLoop(
|
|
config.DefaultConfig(),
|
|
msgBus,
|
|
&startupBlockedProvider{reason: "not used"},
|
|
)
|
|
msgBus.SetEventPublisher(al.RuntimeEventBus())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
sub, eventsCh, err := al.RuntimeEventBus().Channel().OfKind(runtimeevents.KindBusCloseCompleted).SubscribeChan(
|
|
ctx,
|
|
runtimeevents.SubscribeOptions{Name: "bus-close-test", Buffer: 4},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SubscribeChan() error = %v", err)
|
|
}
|
|
defer func() {
|
|
_ = sub.Close()
|
|
}()
|
|
|
|
shutdownGateway(&services{}, al, &startupBlockedProvider{reason: "not used"}, msgBus, true)
|
|
|
|
evt := receiveGatewayRuntimeEvent(t, eventsCh)
|
|
if evt.Kind != runtimeevents.KindBusCloseCompleted {
|
|
t.Fatalf("shutdown event kind = %q, want %q", evt.Kind, runtimeevents.KindBusCloseCompleted)
|
|
}
|
|
if err := msgBus.PublishVoiceControl(context.Background(), bus.VoiceControl{}); !errors.Is(err, bus.ErrBusClosed) {
|
|
t.Fatalf("PublishVoiceControl after shutdown error = %v, want %v", err, bus.ErrBusClosed)
|
|
}
|
|
}
|
|
|
|
func receiveGatewayRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
|
|
t.Helper()
|
|
|
|
select {
|
|
case evt := <-ch:
|
|
return evt
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for gateway runtime event")
|
|
return runtimeevents.Event{}
|
|
}
|
|
}
|