Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
458 lines
15 KiB
Go
458 lines
15 KiB
Go
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
eventprotocol "github.com/tutti-os/tutti/services/tuttid/api/events/generated"
|
|
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
|
|
)
|
|
|
|
type eventStreamEnvelopeFrame struct {
|
|
Event eventprotocol.EventEnvelope `json:"event"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
type eventStreamRawFrame struct {
|
|
Kind string
|
|
Payload []byte
|
|
}
|
|
|
|
type eventStreamErrorFrame struct {
|
|
Kind string `json:"kind"`
|
|
RequestID *string `json:"requestId,omitempty"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func TestTuttidBlackBoxEventStreamWebSocketRejectsMissingAccessToken(t *testing.T) {
|
|
daemon := startTestDaemon(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
defer cancel()
|
|
|
|
streamURL := "ws" + strings.TrimPrefix(daemon.baseURL, "http") + "/v1/events/ws"
|
|
|
|
conn, response, err := websocket.Dial(ctx, streamURL, nil)
|
|
if err == nil {
|
|
_ = conn.Close(websocket.StatusNormalClosure, "test done")
|
|
t.Fatal("websocket Dial() error = nil, want unauthorized failure")
|
|
}
|
|
if response == nil {
|
|
t.Fatalf("websocket response = nil, want HTTP 401; err = %v", err)
|
|
return
|
|
}
|
|
if response.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d; err = %v", response.StatusCode, http.StatusUnauthorized, err)
|
|
}
|
|
}
|
|
|
|
func TestTuttidBlackBoxEventStreamWebSocketReadyAndValidation(t *testing.T) {
|
|
daemon := startTestDaemon(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
defer cancel()
|
|
|
|
conn := mustDialEventStream(t, ctx, daemon)
|
|
defer conn.Close(websocket.StatusNormalClosure, "test done")
|
|
|
|
ready := readEventStreamReadyFrame(t, ctx, conn)
|
|
if ready.Kind != "ready" {
|
|
t.Fatalf("ready kind = %q, want ready", ready.Kind)
|
|
}
|
|
if ready.ProtocolVersion != eventprotocol.BusinessEventProtocolVersion {
|
|
t.Fatalf("protocolVersion = %d, want %d", ready.ProtocolVersion, eventprotocol.BusinessEventProtocolVersion)
|
|
}
|
|
if ready.CatalogRevision != eventprotocol.BusinessEventCatalogRevision {
|
|
t.Fatalf("catalogRevision = %q, want %q", ready.CatalogRevision, eventprotocol.BusinessEventCatalogRevision)
|
|
}
|
|
|
|
writeEventStreamFrame(t, ctx, conn, eventprotocol.ClientPingFrame{
|
|
Kind: "ping",
|
|
RequestID: "ping-1",
|
|
SentAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
})
|
|
pong := readEventStreamPongFrame(t, ctx, conn)
|
|
if pong.Kind != "pong" || pong.RequestID != "ping-1" {
|
|
t.Fatalf("pong = %#v, want pong/ping-1", pong)
|
|
}
|
|
|
|
writeEventStreamFrame(t, ctx, conn, eventprotocol.ClientSubscribeFrame{
|
|
Kind: "subscribe",
|
|
RequestID: "sub-1",
|
|
Topics: []eventprotocol.Topic{"preferences.desktop.unknown"},
|
|
})
|
|
invalidTopic := readEventStreamErrorFrame(t, ctx, conn)
|
|
assertEventStreamErrorFrame(t, invalidTopic, "sub-1", "invalid_topic")
|
|
|
|
writeEventStreamFrame(t, ctx, conn, eventprotocol.ClientSubscribeFrame{
|
|
Kind: "subscribe",
|
|
RequestID: "sub-2",
|
|
Topics: []eventprotocol.Topic{eventprotocol.TopicPreferencesDesktopUpdateRequested},
|
|
})
|
|
invalidDirection := readEventStreamErrorFrame(t, ctx, conn)
|
|
assertEventStreamErrorFrame(t, invalidDirection, "sub-2", "invalid_direction")
|
|
|
|
writeEventStreamFrame(t, ctx, conn, eventprotocol.ClientPublishFrame{
|
|
Kind: "publish",
|
|
RequestID: "pub-1",
|
|
Event: eventprotocol.EventEnvelope{
|
|
Topic: eventprotocol.TopicPreferencesDesktopUpdateRequested,
|
|
Version: 1,
|
|
Payload: mustMarshalRawJSON(t, eventprotocol.PreferencesDesktopUpdateRequestedPayload{
|
|
Preferences: eventprotocol.PreferencesDesktopPreferences{
|
|
AgentConversationDetailMode: "coding",
|
|
DockPlacement: "bottom",
|
|
Locale: "fr",
|
|
MinimizeAnimation: "scale",
|
|
SleepPreventionMode: "never",
|
|
ThemeSource: "dark",
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
invalidPayload := readEventStreamErrorFrame(t, ctx, conn)
|
|
assertEventStreamErrorFrame(t, invalidPayload, "pub-1", "invalid_payload")
|
|
|
|
writeEventStreamPayload(t, ctx, conn, []byte(`{
|
|
"kind":"publish",
|
|
"requestId":"pub-2",
|
|
"event":{
|
|
"topic":"preferences.desktop.update.requested",
|
|
"version":1,
|
|
"payload":{
|
|
"preferences":{"agentCliUpdateCheckEnabled":true,"agentComposerDefaultsByProvider":{},"agentGuiConversationRailCollapsedByProvider":{},"defaultAgentProvider":"codex","dockIconStyle":"default","locale":"en-US",
|
|
"themeSource":"system",
|
|
"unexpected":"value"
|
|
}
|
|
}
|
|
}
|
|
}`))
|
|
invalidPayloadShape := readEventStreamErrorFrame(t, ctx, conn)
|
|
assertEventStreamErrorFrame(t, invalidPayloadShape, "pub-2", "invalid_payload")
|
|
|
|
writeEventStreamPayload(t, ctx, conn, []byte(`{
|
|
"kind":"publish",
|
|
"requestId":"pub-3",
|
|
"event":{
|
|
"topic":"preferences.desktop.update.requested",
|
|
"version":1,
|
|
"payload":{
|
|
"preferences":{"agentCliUpdateCheckEnabled":true,"agentComposerDefaultsByProvider":{},"agentGuiConversationRailCollapsedByProvider":{},"defaultAgentProvider":"codex","dockIconStyle":"default","locale":"en-US",
|
|
"themeSource":"system"
|
|
}
|
|
},
|
|
"unexpected":"value"
|
|
}
|
|
}`))
|
|
invalidEnvelopeShape := readEventStreamErrorFrame(t, ctx, conn)
|
|
assertEventStreamErrorFrame(t, invalidEnvelopeShape, "pub-3", "invalid_payload")
|
|
}
|
|
|
|
func TestTuttidBlackBoxEventStreamPreferenceIntentPublishesUpdatedEvent(t *testing.T) {
|
|
daemon := startTestDaemon(t)
|
|
|
|
initialPreferences := mustRequestJSON[tuttigenerated.DesktopPreferencesStateResponse](
|
|
t,
|
|
daemon,
|
|
http.MethodGet,
|
|
"/v1/preferences/desktop",
|
|
nil,
|
|
http.StatusOK,
|
|
)
|
|
if initialPreferences.Initialized {
|
|
t.Fatalf("initial initialized = true, want false")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
defer cancel()
|
|
|
|
conn := mustDialEventStream(t, ctx, daemon)
|
|
defer conn.Close(websocket.StatusNormalClosure, "test done")
|
|
|
|
_ = readEventStreamReadyFrame(t, ctx, conn)
|
|
|
|
writeEventStreamFrame(t, ctx, conn, eventprotocol.ClientSubscribeFrame{
|
|
Kind: "subscribe",
|
|
RequestID: "sub-1",
|
|
Topics: []eventprotocol.Topic{eventprotocol.TopicPreferencesDesktopUpdated},
|
|
})
|
|
subscribeAck := readEventStreamAckFrame(t, ctx, conn)
|
|
if subscribeAck.Kind != "ack" || subscribeAck.RequestID != "sub-1" {
|
|
t.Fatalf("subscribe ack = %#v, want requestId sub-1", subscribeAck)
|
|
}
|
|
|
|
writeEventStreamFrame(t, ctx, conn, eventprotocol.ClientPublishFrame{
|
|
Kind: "publish",
|
|
RequestID: "pub-1",
|
|
Event: eventprotocol.EventEnvelope{
|
|
ID: "evt-1",
|
|
Topic: eventprotocol.TopicPreferencesDesktopUpdateRequested,
|
|
Version: 1,
|
|
EmittedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Payload: mustMarshalRawJSON(t, eventprotocol.PreferencesDesktopUpdateRequestedPayload{
|
|
Preferences: eventprotocol.PreferencesDesktopPreferences{
|
|
AgentConversationDetailMode: "general",
|
|
AgentDockLayout: "unified",
|
|
AppCatalogChannel: "staging",
|
|
DefaultAgentProvider: "codex",
|
|
|
|
DockIconStyle: "default",
|
|
DockPlacement: "bottom",
|
|
Locale: "zh-CN",
|
|
MinimizeAnimation: "scale",
|
|
SleepPreventionMode: "whileAgentRunning",
|
|
ThemeSource: "dark",
|
|
UpdateChannel: "stable",
|
|
UpdatePolicy: "prompt",
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
|
|
var (
|
|
sawPublishAck bool
|
|
sawUpdated bool
|
|
updated eventprotocol.PreferencesDesktopUpdatedPayload
|
|
)
|
|
|
|
for !sawPublishAck || !sawUpdated {
|
|
frame := readEventStreamRawFrame(t, ctx, conn)
|
|
switch frame.Kind {
|
|
case "ack":
|
|
ack := decodeEventStreamAckFrame(t, frame)
|
|
if ack.RequestID == "pub-1" {
|
|
sawPublishAck = true
|
|
}
|
|
case "event":
|
|
eventFrame := decodeEventStreamEventFrame(t, frame)
|
|
if eventFrame.Event.Topic != eventprotocol.TopicPreferencesDesktopUpdated {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(eventFrame.Event.Payload, &updated); err != nil {
|
|
t.Fatalf("Unmarshal updated payload error = %v; payload: %s", err, string(eventFrame.Event.Payload))
|
|
}
|
|
sawUpdated = true
|
|
case "error":
|
|
t.Fatalf("unexpected error frame = %#v", decodeEventStreamErrorFrame(t, frame))
|
|
default:
|
|
t.Fatalf("unexpected frame kind = %q", frame.Kind)
|
|
}
|
|
}
|
|
|
|
if !updated.Initialized {
|
|
t.Fatal("updated initialized = false, want true")
|
|
}
|
|
if updated.Preferences.DefaultAgentProvider != "codex" ||
|
|
updated.Preferences.AgentConversationDetailMode != "general" ||
|
|
updated.Preferences.AgentDockLayout != "unified" ||
|
|
updated.Preferences.AppCatalogChannel != "staging" ||
|
|
updated.Preferences.DockPlacement != "bottom" ||
|
|
updated.Preferences.Locale != "zh-CN" ||
|
|
updated.Preferences.ThemeSource != "dark" ||
|
|
updated.Preferences.UpdateChannel != "stable" ||
|
|
updated.Preferences.UpdatePolicy != "prompt" {
|
|
t.Fatalf("updated payload = %#v, want staging/codex/general/unified/bottom/zh-CN/dark/stable/prompt", updated)
|
|
}
|
|
|
|
after := mustRequestJSON[tuttigenerated.DesktopPreferencesStateResponse](
|
|
t,
|
|
daemon,
|
|
http.MethodGet,
|
|
"/v1/preferences/desktop",
|
|
nil,
|
|
http.StatusOK,
|
|
)
|
|
if !after.Initialized {
|
|
t.Fatal("stored initialized = false, want true")
|
|
}
|
|
if after.Preferences.Locale != tuttigenerated.ZhCN {
|
|
t.Fatalf("stored locale = %q, want %q", after.Preferences.Locale, tuttigenerated.ZhCN)
|
|
}
|
|
if after.Preferences.DefaultAgentProvider != tuttigenerated.DesktopDefaultAgentProviderCodex {
|
|
t.Fatalf("stored defaultAgentProvider = %q, want %q", after.Preferences.DefaultAgentProvider, tuttigenerated.DesktopDefaultAgentProviderCodex)
|
|
}
|
|
if after.Preferences.AgentConversationDetailMode != tuttigenerated.General {
|
|
t.Fatalf("stored agentConversationDetailMode = %q, want %q", after.Preferences.AgentConversationDetailMode, tuttigenerated.General)
|
|
}
|
|
if after.Preferences.AgentDockLayout != tuttigenerated.Unified {
|
|
t.Fatalf("stored agentDockLayout = %q, want %q", after.Preferences.AgentDockLayout, tuttigenerated.Unified)
|
|
}
|
|
if after.Preferences.AppCatalogChannel != tuttigenerated.Staging {
|
|
t.Fatalf("stored appCatalogChannel = %q, want %q", after.Preferences.AppCatalogChannel, tuttigenerated.Staging)
|
|
}
|
|
if after.Preferences.DockPlacement != tuttigenerated.Bottom {
|
|
t.Fatalf("stored dockPlacement = %q, want %q", after.Preferences.DockPlacement, tuttigenerated.Bottom)
|
|
}
|
|
if after.Preferences.ThemeSource != tuttigenerated.DesktopThemeSourceDark {
|
|
t.Fatalf("stored themeSource = %q, want %q", after.Preferences.ThemeSource, tuttigenerated.DesktopThemeSourceDark)
|
|
}
|
|
}
|
|
|
|
func mustDialEventStream(t *testing.T, ctx context.Context, daemon *testDaemon) *websocket.Conn {
|
|
t.Helper()
|
|
|
|
streamURL := "ws" +
|
|
strings.TrimPrefix(daemon.baseURL, "http") +
|
|
"/v1/events/ws?access_token=" + daemon.accessToken
|
|
|
|
conn, _, err := websocket.Dial(ctx, streamURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("websocket Dial() error = %v", err)
|
|
}
|
|
return conn
|
|
}
|
|
|
|
func mustMarshalRawJSON(t *testing.T, value any) json.RawMessage {
|
|
t.Helper()
|
|
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatalf("Marshal payload error = %v", err)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func writeEventStreamFrame(t *testing.T, ctx context.Context, conn *websocket.Conn, frame any) {
|
|
t.Helper()
|
|
|
|
payload, err := json.Marshal(frame)
|
|
if err != nil {
|
|
t.Fatalf("Marshal event stream frame error = %v", err)
|
|
}
|
|
writeEventStreamPayload(t, ctx, conn, payload)
|
|
}
|
|
|
|
func writeEventStreamPayload(t *testing.T, ctx context.Context, conn *websocket.Conn, payload []byte) {
|
|
t.Helper()
|
|
|
|
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
|
|
t.Fatalf("websocket Write() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func readEventStreamPayload(t *testing.T, ctx context.Context, conn *websocket.Conn) []byte {
|
|
t.Helper()
|
|
|
|
_, payload, err := conn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("websocket Read() error = %v", err)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func readEventStreamRawFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) eventStreamRawFrame {
|
|
t.Helper()
|
|
|
|
payload := readEventStreamPayload(t, ctx, conn)
|
|
var frame struct {
|
|
Kind string `json:"kind"`
|
|
}
|
|
if err := json.Unmarshal(payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal kind frame error = %v; payload: %s", err, string(payload))
|
|
}
|
|
return eventStreamRawFrame{
|
|
Kind: frame.Kind,
|
|
Payload: payload,
|
|
}
|
|
}
|
|
|
|
func readEventStreamReadyFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) eventprotocol.ServerReadyFrame {
|
|
t.Helper()
|
|
|
|
payload := readEventStreamPayload(t, ctx, conn)
|
|
var frame eventprotocol.ServerReadyFrame
|
|
if err := json.Unmarshal(payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal ready frame error = %v; payload: %s", err, string(payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func readEventStreamPongFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) eventprotocol.ServerPongFrame {
|
|
t.Helper()
|
|
|
|
payload := readEventStreamPayload(t, ctx, conn)
|
|
var frame eventprotocol.ServerPongFrame
|
|
if err := json.Unmarshal(payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal pong frame error = %v; payload: %s", err, string(payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func readEventStreamAckFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) eventprotocol.ServerAckFrame {
|
|
t.Helper()
|
|
|
|
payload := readEventStreamPayload(t, ctx, conn)
|
|
var frame eventprotocol.ServerAckFrame
|
|
if err := json.Unmarshal(payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal ack frame error = %v; payload: %s", err, string(payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func decodeEventStreamAckFrame(t *testing.T, raw eventStreamRawFrame) eventprotocol.ServerAckFrame {
|
|
t.Helper()
|
|
if raw.Kind != "ack" {
|
|
t.Fatalf("frame kind = %q, want ack", raw.Kind)
|
|
}
|
|
var frame eventprotocol.ServerAckFrame
|
|
if err := json.Unmarshal(raw.Payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal ack frame error = %v; payload: %s", err, string(raw.Payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func decodeEventStreamEventFrame(t *testing.T, raw eventStreamRawFrame) eventStreamEnvelopeFrame {
|
|
t.Helper()
|
|
if raw.Kind != "event" {
|
|
t.Fatalf("frame kind = %q, want event", raw.Kind)
|
|
}
|
|
var frame eventStreamEnvelopeFrame
|
|
if err := json.Unmarshal(raw.Payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal event frame error = %v; payload: %s", err, string(raw.Payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func readEventStreamErrorFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) eventStreamErrorFrame {
|
|
t.Helper()
|
|
|
|
payload := readEventStreamPayload(t, ctx, conn)
|
|
var frame eventStreamErrorFrame
|
|
if err := json.Unmarshal(payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal error frame error = %v; payload: %s", err, string(payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func decodeEventStreamErrorFrame(t *testing.T, raw eventStreamRawFrame) eventStreamErrorFrame {
|
|
t.Helper()
|
|
if raw.Kind != "error" {
|
|
t.Fatalf("frame kind = %q, want error", raw.Kind)
|
|
}
|
|
var frame eventStreamErrorFrame
|
|
if err := json.Unmarshal(raw.Payload, &frame); err != nil {
|
|
t.Fatalf("Unmarshal error frame error = %v; payload: %s", err, string(raw.Payload))
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func assertEventStreamErrorFrame(t *testing.T, frame eventStreamErrorFrame, requestID string, code string) {
|
|
t.Helper()
|
|
|
|
if frame.Kind != "error" {
|
|
t.Fatalf("frame kind = %q, want error", frame.Kind)
|
|
}
|
|
if frame.RequestID == nil || *frame.RequestID != requestID {
|
|
t.Fatalf("frame requestId = %v, want %q", frame.RequestID, requestID)
|
|
}
|
|
if frame.Code != code {
|
|
t.Fatalf("error code = %q, want %q", frame.Code, code)
|
|
}
|
|
}
|