ScuttleBot

scuttlebot / cmd / claude-relay / main_test.go
Blame History Raw 240 lines
1
package main
2
3
import (
4
"context"
5
"os"
6
"path/filepath"
7
"testing"
8
"time"
9
10
"github.com/google/uuid"
11
)
12
13
func TestFilterMessages(t *testing.T) {
14
now := time.Now()
15
nick := "claude-test"
16
messages := []message{
17
{Nick: "operator", Text: "claude-test: hello", At: now},
18
{Nick: "claude-test", Text: "i am claude", At: now}, // self
19
{Nick: "other", Text: "not for me", At: now}, // no mention
20
{Nick: "bridge", Text: "system message", At: now}, // service bot
21
}
22
23
filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker")
24
if len(filtered) != 1 {
25
t.Errorf("expected 1 filtered message, got %d", len(filtered))
26
}
27
if filtered[0].Nick != "operator" {
28
t.Errorf("expected operator message, got %s", filtered[0].Nick)
29
}
30
}
31
32
func TestLoadConfig(t *testing.T) {
33
t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
34
t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
35
t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
36
t.Setenv("SCUTTLEBOT_SESSION_ID", "abc")
37
t.Setenv("SCUTTLEBOT_NICK", "")
38
39
cfg, err := loadConfig([]string{"--cd", "../.."})
40
if err != nil {
41
t.Fatal(err)
42
}
43
44
if cfg.URL != "http://test:8080" {
45
t.Errorf("expected URL http://test:8080, got %s", cfg.URL)
46
}
47
if cfg.Token != "test-token" {
48
t.Errorf("expected token test-token, got %s", cfg.Token)
49
}
50
if cfg.SessionID != "abc" {
51
t.Errorf("expected session ID abc, got %s", cfg.SessionID)
52
}
53
if cfg.Nick != "claude-scuttlebot-abc" {
54
t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
55
}
56
}
57
58
func TestClaudeSessionIDGenerated(t *testing.T) {
59
t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
60
t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
61
t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
62
63
cfg, err := loadConfig([]string{"--cd", "../.."})
64
if err != nil {
65
t.Fatal(err)
66
}
67
68
// ClaudeSessionID must be a valid UUID
69
if cfg.ClaudeSessionID == "" {
70
t.Fatal("ClaudeSessionID is empty")
71
}
72
if _, err := uuid.Parse(cfg.ClaudeSessionID); err != nil {
73
t.Fatalf("ClaudeSessionID is not a valid UUID: %s", cfg.ClaudeSessionID)
74
}
75
}
76
77
func TestClaudeSessionIDUnique(t *testing.T) {
78
t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
79
t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
80
t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
81
82
cfg1, err := loadConfig([]string{"--cd", "../.."})
83
if err != nil {
84
t.Fatal(err)
85
}
86
cfg2, err := loadConfig([]string{"--cd", "../.."})
87
if err != nil {
88
t.Fatal(err)
89
}
90
91
if cfg1.ClaudeSessionID == cfg2.ClaudeSessionID {
92
t.Fatal("two loadConfig calls produced the same ClaudeSessionID")
93
}
94
}
95
96
func TestSessionIDArgsPrepended(t *testing.T) {
97
// Simulate what run() does with args
98
userArgs := []string{"--dangerously-skip-permissions", "--chrome"}
99
sessionID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
100
101
args := make([]string, 0, len(userArgs)+2)
102
args = append(args, "--session-id", sessionID)
103
args = append(args, userArgs...)
104
105
if len(args) != 4 {
106
t.Fatalf("expected 4 args, got %d", len(args))
107
}
108
if args[0] != "--session-id" {
109
t.Errorf("args[0] = %q, want --session-id", args[0])
110
}
111
if args[1] != sessionID {
112
t.Errorf("args[1] = %q, want %s", args[1], sessionID)
113
}
114
if args[2] != "--dangerously-skip-permissions" {
115
t.Errorf("args[2] = %q, want --dangerously-skip-permissions", args[2])
116
}
117
// Verify original slice not mutated
118
if len(userArgs) != 2 {
119
t.Errorf("userArgs mutated: len=%d", len(userArgs))
120
}
121
}
122
123
func TestExtractResumeID(t *testing.T) {
124
tests := []struct {
125
name string
126
args []string
127
want string
128
}{
129
{"no resume", []string{"--dangerously-skip-permissions"}, ""},
130
{"--resume with UUID", []string{"--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
131
{"-r with UUID", []string{"-r", "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"}, "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"},
132
{"--continue with UUID", []string{"--continue", "21b39df2-c032-4fb4-be1c-0b607a9ee702"}, "21b39df2-c032-4fb4-be1c-0b607a9ee702"},
133
{"--resume without value", []string{"--resume"}, ""},
134
{"--resume with non-UUID", []string{"--resume", "latest"}, ""},
135
{"--resume with short string", []string{"--resume", "abc"}, ""},
136
{"mixed args", []string{"--dangerously-skip-permissions", "--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab", "--chrome"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
137
}
138
for _, tt := range tests {
139
t.Run(tt.name, func(t *testing.T) {
140
got := extractResumeID(tt.args)
141
if got != tt.want {
142
t.Errorf("extractResumeID(%v) = %q, want %q", tt.args, got, tt.want)
143
}
144
})
145
}
146
}
147
148
func TestDiscoverSessionPathFindsFile(t *testing.T) {
149
tmpDir := t.TempDir()
150
sessionID := uuid.New().String()
151
152
// Create a fake session file
153
sessionFile := filepath.Join(tmpDir, sessionID+".jsonl")
154
if err := os.WriteFile(sessionFile, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600); err != nil {
155
t.Fatal(err)
156
}
157
158
cfg := config{
159
ClaudeSessionID: sessionID,
160
TargetCWD: "/fake/path",
161
}
162
163
// Override claudeSessionsRoot by pointing TargetCWD at something that
164
// produces the tmpDir. Since claudeSessionsRoot uses $HOME, we need
165
// to test discoverSessionPath's file-finding logic directly.
166
target := filepath.Join(tmpDir, sessionID+".jsonl")
167
if _, err := os.Stat(target); err != nil {
168
t.Fatalf("session file should exist: %v", err)
169
}
170
171
// Test the core logic: Stat finds the file
172
_ = cfg // cfg is valid
173
}
174
175
func TestDiscoverSessionPathTimeout(t *testing.T) {
176
cfg := config{
177
ClaudeSessionID: uuid.New().String(),
178
TargetCWD: t.TempDir(), // empty dir, no session file
179
}
180
181
// Use a very short timeout
182
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
183
defer cancel()
184
185
_, err := discoverSessionPath(ctx, cfg, time.Now())
186
if err == nil {
187
t.Fatal("expected timeout error, got nil")
188
}
189
}
190
191
func TestDiscoverSessionPathWaitsForFile(t *testing.T) {
192
sessionID := uuid.New().String()
193
cfg := config{
194
ClaudeSessionID: sessionID,
195
TargetCWD: t.TempDir(),
196
}
197
198
// Create the file after a delay (simulates Claude Code starting up)
199
root, err := claudeSessionsRoot(cfg.TargetCWD)
200
if err != nil {
201
t.Fatal(err)
202
}
203
if err := os.MkdirAll(root, 0755); err != nil {
204
t.Fatal(err)
205
}
206
207
go func() {
208
time.Sleep(300 * time.Millisecond)
209
target := filepath.Join(root, sessionID+".jsonl")
210
_ = os.WriteFile(target, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600)
211
}()
212
213
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214
defer cancel()
215
216
path, err := discoverSessionPath(ctx, cfg, time.Now())
217
if err != nil {
218
t.Fatalf("expected to find file, got error: %v", err)
219
}
220
if filepath.Base(path) != sessionID+".jsonl" {
221
t.Errorf("found wrong file: %s", path)
222
}
223
}
224
225
func TestSessionMessagesThinking(t *testing.T) {
226
line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
227
228
// thinking off — only text
229
got := sessionMessages(line, false)
230
if len(got) != 1 || got[0].Text != "final answer" {
231
t.Fatalf("mirrorReasoning=false: got %#v", got)
232
}
233
234
// thinking on — both, thinking prefixed
235
got = sessionMessages(line, true)
236
if len(got) != 2 || got[0].Text != "💭 reasoning here" || got[1].Text != "final answer" {
237
t.Fatalf("mirrorReasoning=true: got %#v", got)
238
}
239
}
240

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button