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