Files
picoclaw/pkg/gateway/gateway_test.go
T
Hoshina 78fd080189 fix(events): keep runtime observers non-blocking
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.
2026-04-27 13:09:03 +08:00

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{}
}
}