Files
picoclaw/pkg/gateway/gateway_test.go
T
2026-06-26 16:11:54 +08:00

300 lines
7.9 KiB
Go

package gateway
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"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 TestCollectGatewayStartupStatusHandlesMalformedInfo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
startupInfo map[string]any
wantToolsCount int
wantSkillsAvailable int
wantSkillsTotal int
wantLogFields map[string]any
}{
{
name: "missing info",
startupInfo: map[string]any{},
wantLogFields: map[string]any{},
},
{
name: "wrong map shapes",
startupInfo: map[string]any{
"tools": "unexpected",
"skills": []any{"unexpected"},
},
wantLogFields: map[string]any{},
},
{
name: "valid startup info",
startupInfo: map[string]any{
"tools": map[string]any{
"count": 3,
},
"skills": map[string]any{
"available": 2,
"total": 5,
},
},
wantToolsCount: 3,
wantSkillsAvailable: 2,
wantSkillsTotal: 5,
wantLogFields: map[string]any{
"tools_count": 3,
"skills_available": 2,
"skills_total": 5,
},
},
{
name: "json number startup info",
startupInfo: map[string]any{
"tools": map[string]any{
"count": float64(4),
},
"skills": map[string]any{
"available": float64(1),
"total": float64(6),
},
},
wantToolsCount: 4,
wantSkillsAvailable: 1,
wantSkillsTotal: 6,
wantLogFields: map[string]any{
"tools_count": 4,
"skills_available": 1,
"skills_total": 6,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := collectGatewayStartupStatus(tt.startupInfo)
if got.toolsCount != tt.wantToolsCount {
t.Fatalf("toolsCount = %d, want %d", got.toolsCount, tt.wantToolsCount)
}
if got.skillsAvailable != tt.wantSkillsAvailable {
t.Fatalf("skillsAvailable = %d, want %d", got.skillsAvailable, tt.wantSkillsAvailable)
}
if got.skillsTotal != tt.wantSkillsTotal {
t.Fatalf("skillsTotal = %d, want %d", got.skillsTotal, tt.wantSkillsTotal)
}
if !reflect.DeepEqual(got.logFields, tt.wantLogFields) {
t.Fatalf("logFields = %#v, want %#v", got.logFields, tt.wantLogFields)
}
})
}
}
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{}
}
}