Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/test/integration/integration_test.go
2867 views
1
package integration_test
2
3
import (
4
"encoding/json"
5
"fmt"
6
"github.com/kardolus/chatgpt-cli/api"
7
"github.com/kardolus/chatgpt-cli/config"
8
"github.com/kardolus/chatgpt-cli/history"
9
"github.com/kardolus/chatgpt-cli/internal"
10
"github.com/kardolus/chatgpt-cli/test"
11
"github.com/onsi/gomega/gexec"
12
"github.com/sclevine/spec"
13
"github.com/sclevine/spec/report"
14
"io"
15
"log"
16
"os"
17
"os/exec"
18
"path"
19
"path/filepath"
20
"strconv"
21
"strings"
22
"sync"
23
"testing"
24
"time"
25
26
. "github.com/onsi/gomega"
27
)
28
29
const (
30
gitCommit = "some-git-commit"
31
gitVersion = "some-git-version"
32
servicePort = ":8080"
33
serviceURL = "http://0.0.0.0" + servicePort
34
)
35
36
var (
37
once sync.Once
38
)
39
40
func TestIntegration(t *testing.T) {
41
defer gexec.CleanupBuildArtifacts()
42
spec.Run(t, "Integration Tests", testIntegration, spec.Report(report.Terminal{}))
43
}
44
45
func testIntegration(t *testing.T, when spec.G, it spec.S) {
46
it.Before(func() {
47
RegisterTestingT(t)
48
Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())
49
Expect(os.Unsetenv(internal.DataHomeEnv)).To(Succeed())
50
})
51
52
when("Read and Write History", func() {
53
const threadName = "default-thread"
54
55
var (
56
tmpDir string
57
fileIO *history.FileIO
58
messages []api.Message
59
err error
60
)
61
62
it.Before(func() {
63
tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")
64
Expect(err).NotTo(HaveOccurred())
65
66
fileIO, _ = history.New()
67
fileIO = fileIO.WithDirectory(tmpDir)
68
fileIO.SetThread(threadName)
69
70
messages = []api.Message{
71
{
72
Role: "user",
73
Content: "Test message 1",
74
},
75
{
76
Role: "assistant",
77
Content: "Test message 2",
78
},
79
}
80
})
81
82
it.After(func() {
83
Expect(os.RemoveAll(tmpDir)).To(Succeed())
84
})
85
86
it("writes the messages to the file", func() {
87
var historyEntries []history.History
88
for _, message := range messages {
89
historyEntries = append(historyEntries, history.History{
90
Message: message,
91
})
92
}
93
94
err = fileIO.Write(historyEntries)
95
Expect(err).NotTo(HaveOccurred())
96
})
97
98
it("reads the messages from the file", func() {
99
var historyEntries []history.History
100
for _, message := range messages {
101
historyEntries = append(historyEntries, history.History{
102
Message: message,
103
})
104
}
105
106
err = fileIO.Write(historyEntries) // need to write before reading
107
Expect(err).NotTo(HaveOccurred())
108
109
readEntries, err := fileIO.Read()
110
Expect(err).NotTo(HaveOccurred())
111
Expect(readEntries).To(Equal(historyEntries))
112
})
113
})
114
115
when("Read, Write, List, Delete Config", func() {
116
var (
117
tmpDir string
118
tmpFile *os.File
119
historyDir string
120
configIO *config.FileIO
121
testConfig config.Config
122
err error
123
)
124
125
it.Before(func() {
126
tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")
127
Expect(err).NotTo(HaveOccurred())
128
129
historyDir, err = os.MkdirTemp(tmpDir, "history")
130
Expect(err).NotTo(HaveOccurred())
131
132
tmpFile, err = os.CreateTemp(tmpDir, "config.yaml")
133
Expect(err).NotTo(HaveOccurred())
134
135
Expect(tmpFile.Close()).To(Succeed())
136
137
configIO = config.NewStore().WithConfigPath(tmpFile.Name()).WithHistoryPath(historyDir)
138
139
testConfig = config.Config{
140
Model: "test-model",
141
}
142
})
143
144
it.After(func() {
145
Expect(os.RemoveAll(tmpDir)).To(Succeed())
146
})
147
148
when("performing a migration", func() {
149
defaults := config.NewStore().ReadDefaults()
150
151
it("it doesn't apply a migration when max_tokens is 0", func() {
152
testConfig.MaxTokens = 0
153
154
err = configIO.Write(testConfig) // need to write before reading
155
Expect(err).NotTo(HaveOccurred())
156
157
readConfig, err := configIO.Read()
158
Expect(err).NotTo(HaveOccurred())
159
Expect(readConfig).To(Equal(testConfig))
160
})
161
it("it migrates small values of max_tokens as expected", func() {
162
testConfig.MaxTokens = defaults.ContextWindow - 1
163
164
err = configIO.Write(testConfig) // need to write before reading
165
Expect(err).NotTo(HaveOccurred())
166
167
readConfig, err := configIO.Read()
168
Expect(err).NotTo(HaveOccurred())
169
170
expectedConfig := testConfig
171
expectedConfig.MaxTokens = defaults.MaxTokens
172
expectedConfig.ContextWindow = defaults.ContextWindow
173
174
Expect(readConfig).To(Equal(expectedConfig))
175
})
176
it("it migrates large values of max_tokens as expected", func() {
177
testConfig.MaxTokens = defaults.ContextWindow + 1
178
179
err = configIO.Write(testConfig) // need to write before reading
180
Expect(err).NotTo(HaveOccurred())
181
182
readConfig, err := configIO.Read()
183
Expect(err).NotTo(HaveOccurred())
184
185
expectedConfig := testConfig
186
expectedConfig.MaxTokens = defaults.MaxTokens
187
expectedConfig.ContextWindow = testConfig.MaxTokens
188
189
Expect(readConfig).To(Equal(expectedConfig))
190
})
191
})
192
193
it("lists all the threads", func() {
194
files := []string{"thread1.json", "thread2.json", "thread3.json"}
195
196
for _, file := range files {
197
file, err := os.Create(filepath.Join(historyDir, file))
198
Expect(err).NotTo(HaveOccurred())
199
200
Expect(file.Close()).To(Succeed())
201
}
202
203
result, err := configIO.List()
204
Expect(err).NotTo(HaveOccurred())
205
Expect(result).To(HaveLen(3))
206
Expect(result[2]).To(Equal("thread3.json"))
207
})
208
209
it("deletes the thread", func() {
210
files := []string{"thread1.json", "thread2.json", "thread3.json"}
211
212
for _, file := range files {
213
file, err := os.Create(filepath.Join(historyDir, file))
214
Expect(err).NotTo(HaveOccurred())
215
216
Expect(file.Close()).To(Succeed())
217
}
218
219
err = configIO.Delete("thread2")
220
Expect(err).NotTo(HaveOccurred())
221
222
_, err = os.Stat(filepath.Join(historyDir, "thread2.json"))
223
Expect(os.IsNotExist(err)).To(BeTrue())
224
225
_, err = os.Stat(filepath.Join(historyDir, "thread3.json"))
226
Expect(os.IsNotExist(err)).To(BeFalse())
227
})
228
})
229
230
when("Performing the Lifecycle", func() {
231
const (
232
exitSuccess = 0
233
exitFailure = 1
234
)
235
236
var (
237
homeDir string
238
filePath string
239
configFile string
240
err error
241
apiKeyEnvVar string
242
)
243
244
runCommand := func(args ...string) string {
245
command := exec.Command(binaryPath, args...)
246
session, err := gexec.Start(command, io.Discard, io.Discard)
247
248
ExpectWithOffset(1, err).NotTo(HaveOccurred())
249
<-session.Exited
250
251
if tmp := string(session.Err.Contents()); tmp != "" {
252
fmt.Printf("error output: %s", string(session.Err.Contents()))
253
}
254
255
ExpectWithOffset(1, session).Should(gexec.Exit(0))
256
return string(session.Out.Contents())
257
}
258
259
runCommandWithStdin := func(stdin io.Reader, args ...string) string {
260
command := exec.Command(binaryPath, args...)
261
command.Stdin = stdin
262
session, err := gexec.Start(command, io.Discard, io.Discard)
263
264
ExpectWithOffset(1, err).NotTo(HaveOccurred())
265
<-session.Exited
266
267
if tmp := string(session.Err.Contents()); tmp != "" {
268
fmt.Printf("error output: %s", tmp)
269
}
270
271
ExpectWithOffset(1, session).Should(gexec.Exit(0))
272
return string(session.Out.Contents())
273
}
274
275
checkConfigFileContent := func(expectedContent string) {
276
content, err := os.ReadFile(configFile)
277
ExpectWithOffset(1, err).NotTo(HaveOccurred())
278
ExpectWithOffset(1, string(content)).To(ContainSubstring(expectedContent))
279
}
280
281
it.Before(func() {
282
once.Do(func() {
283
SetDefaultEventuallyTimeout(10 * time.Second)
284
285
log.Println("Building binary...")
286
Expect(buildBinary()).To(Succeed())
287
log.Println("Binary built successfully!")
288
289
log.Println("Starting mock server...")
290
Expect(runMockServer()).To(Succeed())
291
log.Println("Mock server started!")
292
293
Eventually(func() (string, error) {
294
return curl(fmt.Sprintf("%s/ping", serviceURL))
295
}).Should(ContainSubstring("pong"))
296
})
297
298
homeDir, err = os.MkdirTemp("", "mockHome")
299
Expect(err).NotTo(HaveOccurred())
300
301
apiKeyEnvVar = config.NewManager(config.NewStore()).WithEnvironment().APIKeyEnvVarName()
302
303
Expect(os.Setenv("HOME", homeDir)).To(Succeed())
304
Expect(os.Setenv(apiKeyEnvVar, expectedToken)).To(Succeed())
305
})
306
307
it.After(func() {
308
gexec.Kill()
309
Expect(os.RemoveAll(homeDir))
310
})
311
312
when("resolving the API key", func() {
313
var secretFile string
314
315
it.Before(func() {
316
secretFile = filepath.Join(homeDir, ".chatgpt-cli", "secret.key")
317
Expect(os.MkdirAll(filepath.Dir(secretFile), 0700)).To(Succeed())
318
Expect(os.WriteFile(secretFile, []byte(expectedToken+"\n"), 0600)).To(Succeed())
319
})
320
321
it.After(func() {
322
Expect(os.RemoveAll(filepath.Dir(secretFile))).To(Succeed())
323
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
324
Expect(os.Unsetenv("OPENAI_API_KEY_FILE")).To(Succeed())
325
})
326
327
it("prefers the API key from environment variable over the file", func() {
328
Expect(os.Setenv(apiKeyEnvVar, "env-api-key")).To(Succeed())
329
Expect(os.Setenv("OPENAI_API_KEY_FILE", secretFile)).To(Succeed())
330
331
cmd := exec.Command(binaryPath, "--config")
332
session, err := gexec.Start(cmd, io.Discard, io.Discard)
333
Expect(err).NotTo(HaveOccurred())
334
Eventually(session).Should(gexec.Exit(exitSuccess))
335
output := string(session.Out.Contents())
336
Expect(output).To(ContainSubstring("env-api-key"))
337
})
338
339
it("uses the file if env var is not set", func() {
340
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
341
Expect(os.Setenv("OPENAI_API_KEY_FILE", secretFile)).To(Succeed())
342
343
cmd := exec.Command(binaryPath, "--list-models")
344
session, err := gexec.Start(cmd, io.Discard, io.Discard)
345
Expect(err).NotTo(HaveOccurred())
346
Eventually(session).Should(gexec.Exit(exitSuccess))
347
})
348
349
it("errors if neither env var nor file is set", func() {
350
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
351
Expect(os.Unsetenv("OPENAI_API_KEY_FILE")).To(Succeed())
352
353
cmd := exec.Command(binaryPath, "--list-models")
354
session, err := gexec.Start(cmd, io.Discard, io.Discard)
355
Expect(err).NotTo(HaveOccurred())
356
Eventually(session).Should(gexec.Exit(exitFailure))
357
errOutput := string(session.Err.Contents())
358
Expect(errOutput).To(ContainSubstring("API key is required"))
359
})
360
})
361
362
it("should not require an API key for the --version flag", func() {
363
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
364
365
command := exec.Command(binaryPath, "--version")
366
session, err := gexec.Start(command, io.Discard, io.Discard)
367
Expect(err).NotTo(HaveOccurred())
368
369
Eventually(session).Should(gexec.Exit(exitSuccess))
370
})
371
372
it("should require a hidden folder for the --list-threads flag", func() {
373
command := exec.Command(binaryPath, "--list-threads")
374
session, err := gexec.Start(command, io.Discard, io.Discard)
375
Expect(err).NotTo(HaveOccurred())
376
377
Eventually(session).Should(gexec.Exit(exitFailure))
378
379
output := string(session.Err.Contents())
380
Expect(output).To(ContainSubstring(".chatgpt-cli/history: no such file or directory"))
381
})
382
383
it("should return an error when --new-thread is used with --set-thread", func() {
384
command := exec.Command(binaryPath, "--new-thread", "--set-thread", "some-thread")
385
session, err := gexec.Start(command, io.Discard, io.Discard)
386
Expect(err).NotTo(HaveOccurred())
387
388
Eventually(session).Should(gexec.Exit(exitFailure))
389
390
output := string(session.Err.Contents())
391
Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))
392
})
393
394
it("should return an error when --new-thread is used with --thread", func() {
395
command := exec.Command(binaryPath, "--new-thread", "--thread", "some-thread")
396
session, err := gexec.Start(command, io.Discard, io.Discard)
397
Expect(err).NotTo(HaveOccurred())
398
399
Eventually(session).Should(gexec.Exit(exitFailure))
400
401
output := string(session.Err.Contents())
402
Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))
403
})
404
405
it("should require an argument for the --set-model flag", func() {
406
command := exec.Command(binaryPath, "--set-model")
407
session, err := gexec.Start(command, io.Discard, io.Discard)
408
Expect(err).NotTo(HaveOccurred())
409
410
Eventually(session).Should(gexec.Exit(exitFailure))
411
412
output := string(session.Err.Contents())
413
Expect(output).To(ContainSubstring("flag needs an argument: --set-model"))
414
})
415
416
it("should require an argument for the --set-thread flag", func() {
417
command := exec.Command(binaryPath, "--set-thread")
418
session, err := gexec.Start(command, io.Discard, io.Discard)
419
Expect(err).NotTo(HaveOccurred())
420
421
Eventually(session).Should(gexec.Exit(exitFailure))
422
423
output := string(session.Err.Contents())
424
Expect(output).To(ContainSubstring("flag needs an argument: --set-thread"))
425
})
426
427
it("should require an argument for the --set-max-tokens flag", func() {
428
command := exec.Command(binaryPath, "--set-max-tokens")
429
session, err := gexec.Start(command, io.Discard, io.Discard)
430
Expect(err).NotTo(HaveOccurred())
431
432
Eventually(session).Should(gexec.Exit(exitFailure))
433
434
output := string(session.Err.Contents())
435
Expect(output).To(ContainSubstring("flag needs an argument: --set-max-tokens"))
436
})
437
438
it("should require an argument for the --set-context-window flag", func() {
439
command := exec.Command(binaryPath, "--set-context-window")
440
session, err := gexec.Start(command, io.Discard, io.Discard)
441
Expect(err).NotTo(HaveOccurred())
442
443
Eventually(session).Should(gexec.Exit(exitFailure))
444
445
output := string(session.Err.Contents())
446
Expect(output).To(ContainSubstring("flag needs an argument: --set-context-window"))
447
})
448
449
it("should warn when config.yaml does not exist and OPENAI_CONFIG_HOME is set", func() {
450
configHomeDir := "does-not-exist"
451
Expect(os.Setenv(internal.ConfigHomeEnv, configHomeDir)).To(Succeed())
452
453
configFilePath := path.Join(configHomeDir, "config.yaml")
454
Expect(configFilePath).NotTo(BeAnExistingFile())
455
456
command := exec.Command(binaryPath, "llm query")
457
session, err := gexec.Start(command, io.Discard, io.Discard)
458
Expect(err).NotTo(HaveOccurred())
459
460
Eventually(session).Should(gexec.Exit(exitSuccess))
461
462
output := string(session.Err.Contents())
463
Expect(output).To(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))
464
465
// Unset the variable to prevent pollution
466
Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())
467
})
468
469
it("should NOT warn when config.yaml does not exist and OPENAI_CONFIG_HOME is NOT set", func() {
470
configHomeDir := "does-not-exist"
471
Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())
472
473
configFilePath := path.Join(configHomeDir, "config.yaml")
474
Expect(configFilePath).NotTo(BeAnExistingFile())
475
476
command := exec.Command(binaryPath, "llm query")
477
session, err := gexec.Start(command, io.Discard, io.Discard)
478
Expect(err).NotTo(HaveOccurred())
479
480
Eventually(session).Should(gexec.Exit(exitSuccess))
481
482
output := string(session.Out.Contents())
483
Expect(output).NotTo(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))
484
})
485
486
it("should require the chatgpt-cli folder but not an API key for the --set-model flag", func() {
487
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
488
489
command := exec.Command(binaryPath, "--set-model", "123")
490
session, err := gexec.Start(command, io.Discard, io.Discard)
491
Expect(err).NotTo(HaveOccurred())
492
493
Eventually(session).Should(gexec.Exit(exitFailure))
494
495
output := string(session.Err.Contents())
496
Expect(output).To(ContainSubstring("config directory does not exist:"))
497
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
498
})
499
500
it("should require the chatgpt-cli folder but not an API key for the --set-thread flag", func() {
501
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
502
503
command := exec.Command(binaryPath, "--set-thread", "thread-name")
504
session, err := gexec.Start(command, io.Discard, io.Discard)
505
Expect(err).NotTo(HaveOccurred())
506
507
Eventually(session).Should(gexec.Exit(exitFailure))
508
509
output := string(session.Err.Contents())
510
Expect(output).To(ContainSubstring("config directory does not exist:"))
511
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
512
})
513
514
it("should require the chatgpt-cli folder but not an API key for the --set-max-tokens flag", func() {
515
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
516
517
command := exec.Command(binaryPath, "--set-max-tokens", "789")
518
session, err := gexec.Start(command, io.Discard, io.Discard)
519
Expect(err).NotTo(HaveOccurred())
520
521
Eventually(session).Should(gexec.Exit(exitFailure))
522
523
output := string(session.Err.Contents())
524
Expect(output).To(ContainSubstring("config directory does not exist:"))
525
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
526
})
527
528
it("should require the chatgpt-cli folder but not an API key for the --set-context-window flag", func() {
529
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
530
531
command := exec.Command(binaryPath, "--set-context-window", "789")
532
session, err := gexec.Start(command, io.Discard, io.Discard)
533
Expect(err).NotTo(HaveOccurred())
534
535
Eventually(session).Should(gexec.Exit(exitFailure))
536
537
output := string(session.Err.Contents())
538
Expect(output).To(ContainSubstring("config directory does not exist:"))
539
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
540
})
541
542
it("should return the expected result for the --version flag", func() {
543
output := runCommand("--version")
544
545
Expect(output).To(ContainSubstring(fmt.Sprintf("commit %s", gitCommit)))
546
Expect(output).To(ContainSubstring(fmt.Sprintf("version %s", gitVersion)))
547
})
548
549
it("should return the expected result for the --list-models flag", func() {
550
output := runCommand("--list-models")
551
552
Expect(output).To(ContainSubstring("* gpt-4o (current)"))
553
Expect(output).To(ContainSubstring("- gpt-3.5-turbo"))
554
Expect(output).To(ContainSubstring("- gpt-3.5-turbo-0301"))
555
})
556
557
it("should return the expected result for the --query flag", func() {
558
Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "false")).To(Succeed())
559
560
output := runCommand("--query", "some-query")
561
562
expectedResponse := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
563
Expect(output).To(ContainSubstring(expectedResponse))
564
Expect(output).NotTo(ContainSubstring("Token Usage:"))
565
})
566
567
it("should display token usage after a query when configured to do so", func() {
568
Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "true")).To(Succeed())
569
570
output := runCommand("--query", "tell me a 5 line joke")
571
Expect(output).To(ContainSubstring("Token Usage:"))
572
})
573
574
it("prints debug information with the --debug flag", func() {
575
output := runCommand("--query", "tell me a joke", "--debug")
576
577
Expect(output).To(ContainSubstring("Generated cURL command"))
578
Expect(output).To(ContainSubstring("/v1/chat/completions"))
579
Expect(output).To(ContainSubstring("--header \"Authorization: Bearer ${OPENAI_API_KEY}\""))
580
Expect(output).To(ContainSubstring("--header 'Content-Type: application/json'"))
581
Expect(output).To(ContainSubstring("--header 'User-Agent: chatgpt-cli'"))
582
Expect(output).To(ContainSubstring("\"model\":\"gpt-4o\""))
583
Expect(output).To(ContainSubstring("\"messages\":"))
584
Expect(output).To(ContainSubstring("Response"))
585
586
Expect(os.Unsetenv("OPENAI_DEBUG")).To(Succeed())
587
})
588
589
it("should assemble http errors as expected", func() {
590
Expect(os.Setenv(apiKeyEnvVar, "wrong-token")).To(Succeed())
591
592
command := exec.Command(binaryPath, "--query", "some-query")
593
session, err := gexec.Start(command, io.Discard, io.Discard)
594
Expect(err).NotTo(HaveOccurred())
595
596
Eventually(session).Should(gexec.Exit(exitFailure))
597
598
output := string(session.Err.Contents())
599
600
// see error.json
601
Expect(output).To(Equal("http status 401: Incorrect API key provided\n"))
602
})
603
604
when("loading configuration via --target", func() {
605
var (
606
configDir string
607
mainConfig string
608
targetConfig string
609
)
610
611
it.Before(func() {
612
RegisterTestingT(t)
613
614
var err error
615
configDir, err = os.MkdirTemp("", "chatgpt-cli-test")
616
Expect(err).NotTo(HaveOccurred())
617
618
Expect(os.Setenv("OPENAI_CONFIG_HOME", configDir)).To(Succeed())
619
620
mainConfig = filepath.Join(configDir, "config.yaml")
621
targetConfig = filepath.Join(configDir, "config.testtarget.yaml")
622
623
Expect(os.WriteFile(mainConfig, []byte("model: gpt-4o\n"), 0644)).To(Succeed())
624
Expect(os.WriteFile(targetConfig, []byte("model: gpt-3.5-turbo-0301\n"), 0644)).To(Succeed())
625
})
626
627
it("should load config.testtarget.yaml when using --target", func() {
628
cmd := exec.Command(binaryPath, "--target", "testtarget", "--config")
629
630
session, err := gexec.Start(cmd, io.Discard, io.Discard)
631
Expect(err).NotTo(HaveOccurred())
632
633
Eventually(session).Should(gexec.Exit(0))
634
output := string(session.Out.Contents())
635
Expect(output).To(ContainSubstring("gpt-3.5-turbo-0301"))
636
})
637
638
it("should fall back to config.yaml when --target is not used", func() {
639
cmd := exec.Command(binaryPath, "--config")
640
641
session, err := gexec.Start(cmd, io.Discard, io.Discard)
642
Expect(err).NotTo(HaveOccurred())
643
644
Eventually(session).Should(gexec.Exit(0))
645
output := string(session.Out.Contents())
646
Expect(output).To(ContainSubstring("gpt-4o"))
647
})
648
})
649
650
when("there is a hidden chatgpt-cli folder in the home dir", func() {
651
it.Before(func() {
652
filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")
653
Expect(os.MkdirAll(filePath, 0777)).To(Succeed())
654
})
655
656
it.After(func() {
657
Expect(os.RemoveAll(filePath)).To(Succeed())
658
})
659
660
it("should not require an API key for the --list-threads flag", func() {
661
historyPath := path.Join(filePath, "history")
662
Expect(os.MkdirAll(historyPath, 0777)).To(Succeed())
663
664
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
665
666
command := exec.Command(binaryPath, "--list-threads")
667
session, err := gexec.Start(command, io.Discard, io.Discard)
668
Expect(err).NotTo(HaveOccurred())
669
670
Eventually(session).Should(gexec.Exit(exitSuccess))
671
})
672
673
it("migrates the legacy history as expected", func() {
674
// Legacy history file should not exist
675
legacyFile := path.Join(filePath, "history")
676
Expect(legacyFile).NotTo(BeAnExistingFile())
677
678
// History should not exist yet
679
historyFile := path.Join(filePath, "history", "default.json")
680
Expect(historyFile).NotTo(BeAnExistingFile())
681
682
bytes, err := test.FileToBytes("history.json")
683
Expect(err).NotTo(HaveOccurred())
684
685
Expect(os.WriteFile(legacyFile, bytes, 0644)).To(Succeed())
686
Expect(legacyFile).To(BeARegularFile())
687
688
// Perform a query
689
command := exec.Command(binaryPath, "--query", "some-query")
690
session, err := gexec.Start(command, io.Discard, io.Discard)
691
Expect(err).NotTo(HaveOccurred())
692
693
// The CLI response should be as expected
694
Eventually(session).Should(gexec.Exit(exitSuccess))
695
696
output := string(session.Out.Contents())
697
698
response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
699
Expect(output).To(ContainSubstring(response))
700
701
// The history file should have the expected content
702
Expect(path.Dir(historyFile)).To(BeADirectory())
703
content, err := os.ReadFile(historyFile)
704
705
Expect(err).NotTo(HaveOccurred())
706
Expect(content).NotTo(BeEmpty())
707
Expect(string(content)).To(ContainSubstring(response))
708
709
// The legacy file should now be a directory
710
Expect(legacyFile).To(BeADirectory())
711
Expect(legacyFile).NotTo(BeARegularFile())
712
713
// The content was moved to the new file
714
Expect(string(content)).To(ContainSubstring("Of course! Which city are you referring to?"))
715
})
716
717
it("should not require an API key for the --clear-history flag", func() {
718
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
719
720
command := exec.Command(binaryPath, "--clear-history")
721
session, err := gexec.Start(command, io.Discard, io.Discard)
722
Expect(err).NotTo(HaveOccurred())
723
724
Eventually(session).Should(gexec.Exit(exitSuccess))
725
})
726
727
it("keeps track of history", func() {
728
// History should not exist yet
729
historyDir := path.Join(filePath, "history")
730
historyFile := path.Join(historyDir, "default.json")
731
Expect(historyFile).NotTo(BeAnExistingFile())
732
733
// Perform a query and check response
734
response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
735
output := runCommand("--query", "some-query")
736
Expect(output).To(ContainSubstring(response))
737
738
// Check if history file was created with expected content
739
Expect(historyDir).To(BeADirectory())
740
checkHistoryContent := func(expectedContent string) {
741
content, err := os.ReadFile(historyFile)
742
Expect(err).NotTo(HaveOccurred())
743
Expect(string(content)).To(ContainSubstring(expectedContent))
744
}
745
checkHistoryContent(response)
746
747
// Clear the history using the CLI
748
runCommand("--clear-history")
749
Expect(historyFile).NotTo(BeAnExistingFile())
750
751
// Test omitting history through environment variable
752
omitHistoryEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "OMIT_HISTORY", 1)
753
envValue := "true"
754
Expect(os.Setenv(omitHistoryEnvKey, envValue)).To(Succeed())
755
756
// Perform another query with history omitted
757
runCommand("--query", "some-query")
758
// The history file should NOT be recreated
759
Expect(historyFile).NotTo(BeAnExistingFile())
760
761
// Cleanup: Unset the environment variable
762
Expect(os.Unsetenv(omitHistoryEnvKey)).To(Succeed())
763
})
764
765
it("should not add binary data to the history", func() {
766
historyDir := path.Join(filePath, "history")
767
historyFile := path.Join(historyDir, "default.json")
768
Expect(historyFile).NotTo(BeAnExistingFile())
769
770
response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
771
772
// Create a pipe to simulate binary input
773
r, w := io.Pipe()
774
defer r.Close()
775
776
// Run the command with piped binary input
777
binaryData := []byte{0x00, 0xFF, 0x42, 0x10}
778
go func() {
779
defer w.Close()
780
_, err := w.Write(binaryData)
781
Expect(err).NotTo(HaveOccurred())
782
}()
783
784
// Run the command with stdin redirected
785
output := runCommandWithStdin(r, "--query", "some-query")
786
Expect(output).To(ContainSubstring(response))
787
788
Expect(historyDir).To(BeADirectory())
789
checkHistoryContent := func(expectedContent string) {
790
content, err := os.ReadFile(historyFile)
791
Expect(err).NotTo(HaveOccurred())
792
Expect(string(content)).To(ContainSubstring(expectedContent))
793
}
794
checkHistoryContent(response)
795
})
796
797
it("should return the expected result for the --list-threads flag", func() {
798
historyDir := path.Join(filePath, "history")
799
Expect(os.Mkdir(historyDir, 0755)).To(Succeed())
800
801
files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}
802
803
Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())
804
805
for _, file := range files {
806
file, err := os.Create(filepath.Join(historyDir, file))
807
Expect(err).NotTo(HaveOccurred())
808
809
Expect(file.Close()).To(Succeed())
810
}
811
812
output := runCommand("--list-threads")
813
814
Expect(output).To(ContainSubstring("* default (current)"))
815
Expect(output).To(ContainSubstring("- thread1"))
816
Expect(output).To(ContainSubstring("- thread2"))
817
Expect(output).To(ContainSubstring("- thread3"))
818
})
819
820
it("should delete the expected thread using the --delete-threads flag", func() {
821
historyDir := path.Join(filePath, "history")
822
Expect(os.Mkdir(historyDir, 0755)).To(Succeed())
823
824
files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}
825
826
Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())
827
828
for _, file := range files {
829
file, err := os.Create(filepath.Join(historyDir, file))
830
Expect(err).NotTo(HaveOccurred())
831
832
Expect(file.Close()).To(Succeed())
833
}
834
835
runCommand("--delete-thread", "thread2")
836
837
output := runCommand("--list-threads")
838
839
Expect(output).To(ContainSubstring("* default (current)"))
840
Expect(output).To(ContainSubstring("- thread1"))
841
Expect(output).NotTo(ContainSubstring("- thread2"))
842
Expect(output).To(ContainSubstring("- thread3"))
843
})
844
845
it("should delete the expected threads using the --delete-threads flag with a wildcard", func() {
846
historyDir := filepath.Join(filePath, "history")
847
Expect(os.Mkdir(historyDir, 0755)).To(Succeed())
848
849
files := []string{
850
"start1.json", "start2.json", "start3.json",
851
"1end.json", "2end.json", "3end.json",
852
"1middle1.json", "2middle2.json", "3middle3.json",
853
"other1.json", "other2.json",
854
}
855
856
createTestFiles := func(dir string, filenames []string) {
857
for _, filename := range filenames {
858
file, err := os.Create(filepath.Join(dir, filename))
859
Expect(err).NotTo(HaveOccurred())
860
Expect(file.Close()).To(Succeed())
861
}
862
}
863
864
createTestFiles(historyDir, files)
865
866
output := runCommand("--list-threads")
867
expectedThreads := []string{
868
"start1", "start2", "start3",
869
"1end", "2end", "3end",
870
"1middle1", "2middle2", "3middle3",
871
"other1", "other2",
872
}
873
for _, thread := range expectedThreads {
874
Expect(output).To(ContainSubstring("- " + thread))
875
}
876
877
tests := []struct {
878
pattern string
879
remainingAfter []string
880
}{
881
{"start*", []string{"1end", "2end", "3end", "1middle1", "2middle2", "3middle3", "other1", "other2"}},
882
{"*end", []string{"1middle1", "2middle2", "3middle3", "other1", "other2"}},
883
{"*middle*", []string{"other1", "other2"}},
884
{"*", []string{}}, // Should delete all remaining threads
885
}
886
887
for _, tt := range tests {
888
runCommand("--delete-thread", tt.pattern)
889
output = runCommand("--list-threads")
890
891
for _, thread := range tt.remainingAfter {
892
Expect(output).To(ContainSubstring("- " + thread))
893
}
894
}
895
})
896
897
it("should throw an error when a non-existent thread is deleted using the --delete-threads flag", func() {
898
command := exec.Command(binaryPath, "--delete-thread", "does-not-exist")
899
session, err := gexec.Start(command, io.Discard, io.Discard)
900
Expect(err).NotTo(HaveOccurred())
901
902
Eventually(session).Should(gexec.Exit(exitFailure))
903
})
904
905
it("should not throw an error --clear-history is called without there being a history", func() {
906
command := exec.Command(binaryPath, "--clear-history")
907
session, err := gexec.Start(command, io.Discard, io.Discard)
908
Expect(err).NotTo(HaveOccurred())
909
910
Eventually(session).Should(gexec.Exit(exitSuccess))
911
})
912
913
when("configurable flags are set", func() {
914
it.Before(func() {
915
configFile = path.Join(filePath, "config.yaml")
916
Expect(configFile).NotTo(BeAnExistingFile())
917
})
918
919
it("has a configurable default model", func() {
920
oldModel := "gpt-4o"
921
newModel := "gpt-3.5-turbo-0301"
922
923
// Verify initial model
924
output := runCommand("--list-models")
925
Expect(output).To(ContainSubstring("* " + oldModel + " (current)"))
926
Expect(output).To(ContainSubstring("- " + newModel))
927
928
// Update model
929
runCommand("--set-model", newModel)
930
931
// Check configFile is created and contains the new model
932
Expect(configFile).To(BeAnExistingFile())
933
checkConfigFileContent(newModel)
934
935
// Verify updated model through --list-models
936
output = runCommand("--list-models")
937
938
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
939
})
940
941
it("has a configurable default context-window", func() {
942
defaults := config.NewStore().ReadDefaults()
943
944
// Initial check for default context-window
945
output := runCommand("--config")
946
Expect(output).To(ContainSubstring(strconv.Itoa(defaults.ContextWindow)))
947
948
// Update and verify context-window
949
newContextWindow := "100000"
950
runCommand("--set-context-window", newContextWindow)
951
Expect(configFile).To(BeAnExistingFile())
952
checkConfigFileContent(newContextWindow)
953
954
// Verify update through --config
955
output = runCommand("--config")
956
Expect(output).To(ContainSubstring(newContextWindow))
957
958
// Environment variable takes precedence
959
envContext := "123"
960
modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "CONTEXT_WINDOW", 1)
961
Expect(os.Setenv(modelEnvKey, envContext)).To(Succeed())
962
963
// Verify environment variable override
964
output = runCommand("--config")
965
Expect(output).To(ContainSubstring(envContext))
966
Expect(os.Unsetenv(modelEnvKey)).To(Succeed())
967
})
968
969
it("has a configurable default max-tokens", func() {
970
defaults := config.NewStore().ReadDefaults()
971
972
// Initial check for default max-tokens
973
output := runCommand("--config")
974
Expect(output).To(ContainSubstring(strconv.Itoa(defaults.MaxTokens)))
975
976
// Update and verify max-tokens
977
newMaxTokens := "81724"
978
runCommand("--set-max-tokens", newMaxTokens)
979
Expect(configFile).To(BeAnExistingFile())
980
checkConfigFileContent(newMaxTokens)
981
982
// Verify update through --config
983
output = runCommand("--config")
984
Expect(output).To(ContainSubstring(newMaxTokens))
985
986
// Environment variable takes precedence
987
modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "MAX_TOKENS", 1)
988
Expect(os.Setenv(modelEnvKey, newMaxTokens)).To(Succeed())
989
990
// Verify environment variable override
991
output = runCommand("--config")
992
Expect(output).To(ContainSubstring(newMaxTokens))
993
Expect(os.Unsetenv(modelEnvKey)).To(Succeed())
994
})
995
996
it("has a configurable default thread", func() {
997
defaults := config.NewStore().ReadDefaults()
998
999
// Initial check for default thread
1000
output := runCommand("--config")
1001
Expect(output).To(ContainSubstring(defaults.Thread))
1002
1003
// Update and verify thread
1004
newThread := "new-thread"
1005
runCommand("--set-thread", newThread)
1006
Expect(configFile).To(BeAnExistingFile())
1007
checkConfigFileContent(newThread)
1008
1009
// Verify update through --config
1010
output = runCommand("--config")
1011
Expect(output).To(ContainSubstring(newThread))
1012
1013
// Environment variable takes precedence
1014
threadEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "THREAD", 1)
1015
Expect(os.Setenv(threadEnvKey, newThread)).To(Succeed())
1016
1017
// Verify environment variable override
1018
output = runCommand("--config")
1019
Expect(output).To(ContainSubstring(newThread))
1020
Expect(os.Unsetenv(threadEnvKey)).To(Succeed())
1021
})
1022
})
1023
})
1024
1025
when("configuration precedence", func() {
1026
var (
1027
defaultModel = "gpt-4o"
1028
newModel = "gpt-3.5-turbo-0301"
1029
envModel = "gpt-3.5-env-model"
1030
envVar string
1031
)
1032
1033
it.Before(func() {
1034
envVar = strings.Replace(apiKeyEnvVar, "API_KEY", "MODEL", 1)
1035
filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")
1036
Expect(os.MkdirAll(filePath, 0777)).To(Succeed())
1037
1038
configFile = path.Join(filePath, "config.yaml")
1039
Expect(configFile).NotTo(BeAnExistingFile())
1040
})
1041
1042
it("uses environment variable over config file", func() {
1043
// Step 1: Set a model in the config file.
1044
runCommand("--set-model", newModel)
1045
checkConfigFileContent(newModel)
1046
1047
// Step 2: Verify the model from config is used.
1048
output := runCommand("--list-models")
1049
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
1050
1051
// Step 3: Set environment variable and verify it takes precedence.
1052
Expect(os.Setenv(envVar, envModel)).To(Succeed())
1053
output = runCommand("--list-models")
1054
Expect(output).To(ContainSubstring("* " + envModel + " (current)"))
1055
1056
// Step 4: Unset environment variable and verify it falls back to config file.
1057
Expect(os.Unsetenv(envVar)).To(Succeed())
1058
output = runCommand("--list-models")
1059
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
1060
})
1061
1062
it("uses command-line flag over environment variable", func() {
1063
// Step 1: Set environment variable.
1064
Expect(os.Setenv(envVar, envModel)).To(Succeed())
1065
1066
// Step 2: Verify environment variable does not override flag.
1067
output := runCommand("--model", newModel, "--list-models")
1068
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
1069
})
1070
1071
it("falls back to default when config and env are absent", func() {
1072
// Step 1: Ensure no config file and no environment variable.
1073
Expect(os.Unsetenv(envVar)).To(Succeed())
1074
1075
// Step 2: Verify it falls back to the default model.
1076
output := runCommand("--list-models")
1077
Expect(output).To(ContainSubstring("* " + defaultModel + " (current)"))
1078
})
1079
})
1080
1081
when("show-history flag is used", func() {
1082
var tmpDir string
1083
var err error
1084
var historyFile string
1085
1086
it.Before(func() {
1087
RegisterTestingT(t)
1088
tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")
1089
Expect(err).NotTo(HaveOccurred())
1090
historyFile = filepath.Join(tmpDir, "default.json")
1091
1092
messages := []api.Message{
1093
{Role: "user", Content: "Hello"},
1094
{Role: "assistant", Content: "Hi, how can I help you?"},
1095
{Role: "user", Content: "Tell me about the weather"},
1096
{Role: "assistant", Content: "It's sunny today."},
1097
}
1098
data, err := json.Marshal(messages)
1099
Expect(err).NotTo(HaveOccurred())
1100
1101
Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())
1102
1103
// This is legacy: we need a config dir in order to have a history dir
1104
filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")
1105
Expect(os.MkdirAll(filePath, 0777)).To(Succeed())
1106
1107
Expect(os.Setenv("OPENAI_DATA_HOME", tmpDir)).To(Succeed())
1108
})
1109
1110
it("prints the history for the default thread", func() {
1111
output := runCommand("--show-history")
1112
1113
// Check that the output contains the history as expected
1114
Expect(output).To(ContainSubstring("**USER** 👤:\nHello"))
1115
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nHi, how can I help you?"))
1116
Expect(output).To(ContainSubstring("**USER** 👤:\nTell me about the weather"))
1117
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nIt's sunny today."))
1118
})
1119
1120
it("prints the history for a specific thread when specified", func() {
1121
specificThread := "specific-thread"
1122
specificHistoryFile := filepath.Join(tmpDir, specificThread+".json")
1123
1124
// Create a specific thread with custom history
1125
messages := []api.Message{
1126
{Role: "user", Content: "What's the capital of Belgium?"},
1127
{Role: "assistant", Content: "The capital of Belgium is Brussels."},
1128
}
1129
data, err := json.Marshal(messages)
1130
Expect(err).NotTo(HaveOccurred())
1131
Expect(os.WriteFile(specificHistoryFile, data, 0644)).To(Succeed())
1132
1133
// Run the --show-history flag with the specific thread
1134
output := runCommand("--show-history", specificThread)
1135
1136
// Check that the output contains the history as expected
1137
Expect(output).To(ContainSubstring("**USER** 👤:\nWhat's the capital of Belgium?"))
1138
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThe capital of Belgium is Brussels."))
1139
})
1140
1141
it("concatenates user messages correctly", func() {
1142
// Create history where two user messages are concatenated
1143
messages := []api.Message{
1144
{Role: "user", Content: "Part one"},
1145
{Role: "user", Content: " and part two"},
1146
{Role: "assistant", Content: "This is a response."},
1147
}
1148
data, err := json.Marshal(messages)
1149
Expect(err).NotTo(HaveOccurred())
1150
Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())
1151
1152
output := runCommand("--show-history")
1153
1154
// Check that the concatenated user messages are displayed correctly
1155
Expect(output).To(ContainSubstring("**USER** 👤:\nPart one and part two"))
1156
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThis is a response."))
1157
})
1158
})
1159
})
1160
}
1161
1162