Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/test/integration/integration_test.go
2649 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
it("throws an error when the API key is missing", func() {
313
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
314
315
command := exec.Command(binaryPath, "some prompt")
316
session, err := gexec.Start(command, io.Discard, io.Discard)
317
Expect(err).NotTo(HaveOccurred())
318
319
Eventually(session).Should(gexec.Exit(exitFailure))
320
321
output := string(session.Err.Contents())
322
Expect(output).To(ContainSubstring("API key is required."))
323
})
324
325
it("should not require an API key for the --version flag", func() {
326
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
327
328
command := exec.Command(binaryPath, "--version")
329
session, err := gexec.Start(command, io.Discard, io.Discard)
330
Expect(err).NotTo(HaveOccurred())
331
332
Eventually(session).Should(gexec.Exit(exitSuccess))
333
})
334
335
it("should require a hidden folder for the --list-threads flag", func() {
336
command := exec.Command(binaryPath, "--list-threads")
337
session, err := gexec.Start(command, io.Discard, io.Discard)
338
Expect(err).NotTo(HaveOccurred())
339
340
Eventually(session).Should(gexec.Exit(exitFailure))
341
342
output := string(session.Err.Contents())
343
Expect(output).To(ContainSubstring(".chatgpt-cli/history: no such file or directory"))
344
})
345
346
it("should return an error when --new-thread is used with --set-thread", func() {
347
command := exec.Command(binaryPath, "--new-thread", "--set-thread", "some-thread")
348
session, err := gexec.Start(command, io.Discard, io.Discard)
349
Expect(err).NotTo(HaveOccurred())
350
351
Eventually(session).Should(gexec.Exit(exitFailure))
352
353
output := string(session.Err.Contents())
354
Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))
355
})
356
357
it("should return an error when --new-thread is used with --thread", func() {
358
command := exec.Command(binaryPath, "--new-thread", "--thread", "some-thread")
359
session, err := gexec.Start(command, io.Discard, io.Discard)
360
Expect(err).NotTo(HaveOccurred())
361
362
Eventually(session).Should(gexec.Exit(exitFailure))
363
364
output := string(session.Err.Contents())
365
Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))
366
})
367
368
it("should require an argument for the --set-model flag", func() {
369
command := exec.Command(binaryPath, "--set-model")
370
session, err := gexec.Start(command, io.Discard, io.Discard)
371
Expect(err).NotTo(HaveOccurred())
372
373
Eventually(session).Should(gexec.Exit(exitFailure))
374
375
output := string(session.Err.Contents())
376
Expect(output).To(ContainSubstring("flag needs an argument: --set-model"))
377
})
378
379
it("should require an argument for the --set-thread flag", func() {
380
command := exec.Command(binaryPath, "--set-thread")
381
session, err := gexec.Start(command, io.Discard, io.Discard)
382
Expect(err).NotTo(HaveOccurred())
383
384
Eventually(session).Should(gexec.Exit(exitFailure))
385
386
output := string(session.Err.Contents())
387
Expect(output).To(ContainSubstring("flag needs an argument: --set-thread"))
388
})
389
390
it("should require an argument for the --set-max-tokens flag", func() {
391
command := exec.Command(binaryPath, "--set-max-tokens")
392
session, err := gexec.Start(command, io.Discard, io.Discard)
393
Expect(err).NotTo(HaveOccurred())
394
395
Eventually(session).Should(gexec.Exit(exitFailure))
396
397
output := string(session.Err.Contents())
398
Expect(output).To(ContainSubstring("flag needs an argument: --set-max-tokens"))
399
})
400
401
it("should require an argument for the --set-context-window flag", func() {
402
command := exec.Command(binaryPath, "--set-context-window")
403
session, err := gexec.Start(command, io.Discard, io.Discard)
404
Expect(err).NotTo(HaveOccurred())
405
406
Eventually(session).Should(gexec.Exit(exitFailure))
407
408
output := string(session.Err.Contents())
409
Expect(output).To(ContainSubstring("flag needs an argument: --set-context-window"))
410
})
411
412
it("should warn when config.yaml does not exist and OPENAI_CONFIG_HOME is set", func() {
413
configHomeDir := "does-not-exist"
414
Expect(os.Setenv(internal.ConfigHomeEnv, configHomeDir)).To(Succeed())
415
416
configFilePath := path.Join(configHomeDir, "config.yaml")
417
Expect(configFilePath).NotTo(BeAnExistingFile())
418
419
command := exec.Command(binaryPath, "llm query")
420
session, err := gexec.Start(command, io.Discard, io.Discard)
421
Expect(err).NotTo(HaveOccurred())
422
423
Eventually(session).Should(gexec.Exit(exitSuccess))
424
425
output := string(session.Err.Contents())
426
Expect(output).To(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))
427
428
// Unset the variable to prevent pollution
429
Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())
430
})
431
432
it("should NOT warn when config.yaml does not exist and OPENAI_CONFIG_HOME is NOT set", func() {
433
configHomeDir := "does-not-exist"
434
Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())
435
436
configFilePath := path.Join(configHomeDir, "config.yaml")
437
Expect(configFilePath).NotTo(BeAnExistingFile())
438
439
command := exec.Command(binaryPath, "llm query")
440
session, err := gexec.Start(command, io.Discard, io.Discard)
441
Expect(err).NotTo(HaveOccurred())
442
443
Eventually(session).Should(gexec.Exit(exitSuccess))
444
445
output := string(session.Out.Contents())
446
Expect(output).NotTo(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))
447
})
448
449
it("should require the chatgpt-cli folder but not an API key for the --set-model flag", func() {
450
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
451
452
command := exec.Command(binaryPath, "--set-model", "123")
453
session, err := gexec.Start(command, io.Discard, io.Discard)
454
Expect(err).NotTo(HaveOccurred())
455
456
Eventually(session).Should(gexec.Exit(exitFailure))
457
458
output := string(session.Err.Contents())
459
Expect(output).To(ContainSubstring("config directory does not exist:"))
460
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
461
})
462
463
it("should require the chatgpt-cli folder but not an API key for the --set-thread flag", func() {
464
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
465
466
command := exec.Command(binaryPath, "--set-thread", "thread-name")
467
session, err := gexec.Start(command, io.Discard, io.Discard)
468
Expect(err).NotTo(HaveOccurred())
469
470
Eventually(session).Should(gexec.Exit(exitFailure))
471
472
output := string(session.Err.Contents())
473
Expect(output).To(ContainSubstring("config directory does not exist:"))
474
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
475
})
476
477
it("should require the chatgpt-cli folder but not an API key for the --set-max-tokens flag", func() {
478
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
479
480
command := exec.Command(binaryPath, "--set-max-tokens", "789")
481
session, err := gexec.Start(command, io.Discard, io.Discard)
482
Expect(err).NotTo(HaveOccurred())
483
484
Eventually(session).Should(gexec.Exit(exitFailure))
485
486
output := string(session.Err.Contents())
487
Expect(output).To(ContainSubstring("config directory does not exist:"))
488
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
489
})
490
491
it("should require the chatgpt-cli folder but not an API key for the --set-context-window flag", func() {
492
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
493
494
command := exec.Command(binaryPath, "--set-context-window", "789")
495
session, err := gexec.Start(command, io.Discard, io.Discard)
496
Expect(err).NotTo(HaveOccurred())
497
498
Eventually(session).Should(gexec.Exit(exitFailure))
499
500
output := string(session.Err.Contents())
501
Expect(output).To(ContainSubstring("config directory does not exist:"))
502
Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))
503
})
504
505
it("should return the expected result for the --version flag", func() {
506
output := runCommand("--version")
507
508
Expect(output).To(ContainSubstring(fmt.Sprintf("commit %s", gitCommit)))
509
Expect(output).To(ContainSubstring(fmt.Sprintf("version %s", gitVersion)))
510
})
511
512
it("should return the expected result for the --list-models flag", func() {
513
output := runCommand("--list-models")
514
515
Expect(output).To(ContainSubstring("* gpt-4o (current)"))
516
Expect(output).To(ContainSubstring("- gpt-3.5-turbo"))
517
Expect(output).To(ContainSubstring("- gpt-3.5-turbo-0301"))
518
})
519
520
it("should return the expected result for the --query flag", func() {
521
Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "false")).To(Succeed())
522
523
output := runCommand("--query", "some-query")
524
525
expectedResponse := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
526
Expect(output).To(ContainSubstring(expectedResponse))
527
Expect(output).NotTo(ContainSubstring("Token Usage:"))
528
})
529
530
it("should display token usage after a query when configured to do so", func() {
531
Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "true")).To(Succeed())
532
533
output := runCommand("--query", "tell me a 5 line joke")
534
Expect(output).To(ContainSubstring("Token Usage:"))
535
})
536
537
it("prints debug information with the --debug flag", func() {
538
output := runCommand("--query", "tell me a joke", "--debug")
539
540
Expect(output).To(ContainSubstring("Generated cURL command"))
541
Expect(output).To(ContainSubstring("/v1/chat/completions"))
542
Expect(output).To(ContainSubstring("--header \"Authorization: Bearer ${OPENAI_API_KEY}\""))
543
Expect(output).To(ContainSubstring("--header 'Content-Type: application/json'"))
544
Expect(output).To(ContainSubstring("\"model\":\"gpt-4o\""))
545
Expect(output).To(ContainSubstring("\"messages\":"))
546
Expect(output).To(ContainSubstring("Response"))
547
548
Expect(os.Unsetenv("OPENAI_DEBUG")).To(Succeed())
549
})
550
551
it("should assemble http errors as expected", func() {
552
Expect(os.Setenv(apiKeyEnvVar, "wrong-token")).To(Succeed())
553
554
command := exec.Command(binaryPath, "--query", "some-query")
555
session, err := gexec.Start(command, io.Discard, io.Discard)
556
Expect(err).NotTo(HaveOccurred())
557
558
Eventually(session).Should(gexec.Exit(exitFailure))
559
560
output := string(session.Err.Contents())
561
562
// see error.json
563
Expect(output).To(Equal("http status 401: Incorrect API key provided\n"))
564
})
565
566
when("there is a hidden chatgpt-cli folder in the home dir", func() {
567
it.Before(func() {
568
filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")
569
Expect(os.MkdirAll(filePath, 0777)).To(Succeed())
570
})
571
572
it.After(func() {
573
Expect(os.RemoveAll(filePath)).To(Succeed())
574
})
575
576
it("should not require an API key for the --list-threads flag", func() {
577
historyPath := path.Join(filePath, "history")
578
Expect(os.MkdirAll(historyPath, 0777)).To(Succeed())
579
580
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
581
582
command := exec.Command(binaryPath, "--list-threads")
583
session, err := gexec.Start(command, io.Discard, io.Discard)
584
Expect(err).NotTo(HaveOccurred())
585
586
Eventually(session).Should(gexec.Exit(exitSuccess))
587
})
588
589
it("migrates the legacy history as expected", func() {
590
// Legacy history file should not exist
591
legacyFile := path.Join(filePath, "history")
592
Expect(legacyFile).NotTo(BeAnExistingFile())
593
594
// History should not exist yet
595
historyFile := path.Join(filePath, "history", "default.json")
596
Expect(historyFile).NotTo(BeAnExistingFile())
597
598
bytes, err := test.FileToBytes("history.json")
599
Expect(err).NotTo(HaveOccurred())
600
601
Expect(os.WriteFile(legacyFile, bytes, 0644)).To(Succeed())
602
Expect(legacyFile).To(BeARegularFile())
603
604
// Perform a query
605
command := exec.Command(binaryPath, "--query", "some-query")
606
session, err := gexec.Start(command, io.Discard, io.Discard)
607
Expect(err).NotTo(HaveOccurred())
608
609
// The CLI response should be as expected
610
Eventually(session).Should(gexec.Exit(exitSuccess))
611
612
output := string(session.Out.Contents())
613
614
response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
615
Expect(output).To(ContainSubstring(response))
616
617
// The history file should have the expected content
618
Expect(path.Dir(historyFile)).To(BeADirectory())
619
content, err := os.ReadFile(historyFile)
620
621
Expect(err).NotTo(HaveOccurred())
622
Expect(content).NotTo(BeEmpty())
623
Expect(string(content)).To(ContainSubstring(response))
624
625
// The legacy file should now be a directory
626
Expect(legacyFile).To(BeADirectory())
627
Expect(legacyFile).NotTo(BeARegularFile())
628
629
// The content was moved to the new file
630
Expect(string(content)).To(ContainSubstring("Of course! Which city are you referring to?"))
631
})
632
633
it("should not require an API key for the --clear-history flag", func() {
634
Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())
635
636
command := exec.Command(binaryPath, "--clear-history")
637
session, err := gexec.Start(command, io.Discard, io.Discard)
638
Expect(err).NotTo(HaveOccurred())
639
640
Eventually(session).Should(gexec.Exit(exitSuccess))
641
})
642
643
it("keeps track of history", func() {
644
// History should not exist yet
645
historyDir := path.Join(filePath, "history")
646
historyFile := path.Join(historyDir, "default.json")
647
Expect(historyFile).NotTo(BeAnExistingFile())
648
649
// Perform a query and check response
650
response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
651
output := runCommand("--query", "some-query")
652
Expect(output).To(ContainSubstring(response))
653
654
// Check if history file was created with expected content
655
Expect(historyDir).To(BeADirectory())
656
checkHistoryContent := func(expectedContent string) {
657
content, err := os.ReadFile(historyFile)
658
Expect(err).NotTo(HaveOccurred())
659
Expect(string(content)).To(ContainSubstring(expectedContent))
660
}
661
checkHistoryContent(response)
662
663
// Clear the history using the CLI
664
runCommand("--clear-history")
665
Expect(historyFile).NotTo(BeAnExistingFile())
666
667
// Test omitting history through environment variable
668
omitHistoryEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "OMIT_HISTORY", 1)
669
envValue := "true"
670
Expect(os.Setenv(omitHistoryEnvKey, envValue)).To(Succeed())
671
672
// Perform another query with history omitted
673
runCommand("--query", "some-query")
674
// The history file should NOT be recreated
675
Expect(historyFile).NotTo(BeAnExistingFile())
676
677
// Cleanup: Unset the environment variable
678
Expect(os.Unsetenv(omitHistoryEnvKey)).To(Succeed())
679
})
680
681
it("should not add binary data to the history", func() {
682
historyDir := path.Join(filePath, "history")
683
historyFile := path.Join(historyDir, "default.json")
684
Expect(historyFile).NotTo(BeAnExistingFile())
685
686
response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`
687
688
// Create a pipe to simulate binary input
689
r, w := io.Pipe()
690
defer r.Close()
691
692
// Run the command with piped binary input
693
binaryData := []byte{0x00, 0xFF, 0x42, 0x10}
694
go func() {
695
defer w.Close()
696
_, err := w.Write(binaryData)
697
Expect(err).NotTo(HaveOccurred())
698
}()
699
700
// Run the command with stdin redirected
701
output := runCommandWithStdin(r, "--query", "some-query")
702
Expect(output).To(ContainSubstring(response))
703
704
Expect(historyDir).To(BeADirectory())
705
checkHistoryContent := func(expectedContent string) {
706
content, err := os.ReadFile(historyFile)
707
Expect(err).NotTo(HaveOccurred())
708
Expect(string(content)).To(ContainSubstring(expectedContent))
709
}
710
checkHistoryContent(response)
711
})
712
713
it("should return the expected result for the --list-threads flag", func() {
714
historyDir := path.Join(filePath, "history")
715
Expect(os.Mkdir(historyDir, 0755)).To(Succeed())
716
717
files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}
718
719
Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())
720
721
for _, file := range files {
722
file, err := os.Create(filepath.Join(historyDir, file))
723
Expect(err).NotTo(HaveOccurred())
724
725
Expect(file.Close()).To(Succeed())
726
}
727
728
output := runCommand("--list-threads")
729
730
Expect(output).To(ContainSubstring("* default (current)"))
731
Expect(output).To(ContainSubstring("- thread1"))
732
Expect(output).To(ContainSubstring("- thread2"))
733
Expect(output).To(ContainSubstring("- thread3"))
734
})
735
736
it("should delete the expected thread using the --delete-threads flag", func() {
737
historyDir := path.Join(filePath, "history")
738
Expect(os.Mkdir(historyDir, 0755)).To(Succeed())
739
740
files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}
741
742
Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())
743
744
for _, file := range files {
745
file, err := os.Create(filepath.Join(historyDir, file))
746
Expect(err).NotTo(HaveOccurred())
747
748
Expect(file.Close()).To(Succeed())
749
}
750
751
runCommand("--delete-thread", "thread2")
752
753
output := runCommand("--list-threads")
754
755
Expect(output).To(ContainSubstring("* default (current)"))
756
Expect(output).To(ContainSubstring("- thread1"))
757
Expect(output).NotTo(ContainSubstring("- thread2"))
758
Expect(output).To(ContainSubstring("- thread3"))
759
})
760
761
it("should delete the expected threads using the --delete-threads flag with a wildcard", func() {
762
historyDir := filepath.Join(filePath, "history")
763
Expect(os.Mkdir(historyDir, 0755)).To(Succeed())
764
765
files := []string{
766
"start1.json", "start2.json", "start3.json",
767
"1end.json", "2end.json", "3end.json",
768
"1middle1.json", "2middle2.json", "3middle3.json",
769
"other1.json", "other2.json",
770
}
771
772
createTestFiles := func(dir string, filenames []string) {
773
for _, filename := range filenames {
774
file, err := os.Create(filepath.Join(dir, filename))
775
Expect(err).NotTo(HaveOccurred())
776
Expect(file.Close()).To(Succeed())
777
}
778
}
779
780
createTestFiles(historyDir, files)
781
782
output := runCommand("--list-threads")
783
expectedThreads := []string{
784
"start1", "start2", "start3",
785
"1end", "2end", "3end",
786
"1middle1", "2middle2", "3middle3",
787
"other1", "other2",
788
}
789
for _, thread := range expectedThreads {
790
Expect(output).To(ContainSubstring("- " + thread))
791
}
792
793
tests := []struct {
794
pattern string
795
remainingAfter []string
796
}{
797
{"start*", []string{"1end", "2end", "3end", "1middle1", "2middle2", "3middle3", "other1", "other2"}},
798
{"*end", []string{"1middle1", "2middle2", "3middle3", "other1", "other2"}},
799
{"*middle*", []string{"other1", "other2"}},
800
{"*", []string{}}, // Should delete all remaining threads
801
}
802
803
for _, tt := range tests {
804
runCommand("--delete-thread", tt.pattern)
805
output = runCommand("--list-threads")
806
807
for _, thread := range tt.remainingAfter {
808
Expect(output).To(ContainSubstring("- " + thread))
809
}
810
}
811
})
812
813
it("should throw an error when a non-existent thread is deleted using the --delete-threads flag", func() {
814
command := exec.Command(binaryPath, "--delete-thread", "does-not-exist")
815
session, err := gexec.Start(command, io.Discard, io.Discard)
816
Expect(err).NotTo(HaveOccurred())
817
818
Eventually(session).Should(gexec.Exit(exitFailure))
819
})
820
821
it("should not throw an error --clear-history is called without there being a history", func() {
822
command := exec.Command(binaryPath, "--clear-history")
823
session, err := gexec.Start(command, io.Discard, io.Discard)
824
Expect(err).NotTo(HaveOccurred())
825
826
Eventually(session).Should(gexec.Exit(exitSuccess))
827
})
828
829
when("configurable flags are set", func() {
830
it.Before(func() {
831
configFile = path.Join(filePath, "config.yaml")
832
Expect(configFile).NotTo(BeAnExistingFile())
833
})
834
835
it("has a configurable default model", func() {
836
oldModel := "gpt-4o"
837
newModel := "gpt-3.5-turbo-0301"
838
839
// Verify initial model
840
output := runCommand("--list-models")
841
Expect(output).To(ContainSubstring("* " + oldModel + " (current)"))
842
Expect(output).To(ContainSubstring("- " + newModel))
843
844
// Update model
845
runCommand("--set-model", newModel)
846
847
// Check configFile is created and contains the new model
848
Expect(configFile).To(BeAnExistingFile())
849
checkConfigFileContent(newModel)
850
851
// Verify updated model through --list-models
852
output = runCommand("--list-models")
853
854
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
855
})
856
857
it("has a configurable default context-window", func() {
858
defaults := config.NewStore().ReadDefaults()
859
860
// Initial check for default context-window
861
output := runCommand("--config")
862
Expect(output).To(ContainSubstring(strconv.Itoa(defaults.ContextWindow)))
863
864
// Update and verify context-window
865
newContextWindow := "100000"
866
runCommand("--set-context-window", newContextWindow)
867
Expect(configFile).To(BeAnExistingFile())
868
checkConfigFileContent(newContextWindow)
869
870
// Verify update through --config
871
output = runCommand("--config")
872
Expect(output).To(ContainSubstring(newContextWindow))
873
874
// Environment variable takes precedence
875
envContext := "123"
876
modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "CONTEXT_WINDOW", 1)
877
Expect(os.Setenv(modelEnvKey, envContext)).To(Succeed())
878
879
// Verify environment variable override
880
output = runCommand("--config")
881
Expect(output).To(ContainSubstring(envContext))
882
Expect(os.Unsetenv(modelEnvKey)).To(Succeed())
883
})
884
885
it("has a configurable default max-tokens", func() {
886
defaults := config.NewStore().ReadDefaults()
887
888
// Initial check for default max-tokens
889
output := runCommand("--config")
890
Expect(output).To(ContainSubstring(strconv.Itoa(defaults.MaxTokens)))
891
892
// Update and verify max-tokens
893
newMaxTokens := "81724"
894
runCommand("--set-max-tokens", newMaxTokens)
895
Expect(configFile).To(BeAnExistingFile())
896
checkConfigFileContent(newMaxTokens)
897
898
// Verify update through --config
899
output = runCommand("--config")
900
Expect(output).To(ContainSubstring(newMaxTokens))
901
902
// Environment variable takes precedence
903
modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "MAX_TOKENS", 1)
904
Expect(os.Setenv(modelEnvKey, newMaxTokens)).To(Succeed())
905
906
// Verify environment variable override
907
output = runCommand("--config")
908
Expect(output).To(ContainSubstring(newMaxTokens))
909
Expect(os.Unsetenv(modelEnvKey)).To(Succeed())
910
})
911
912
it("has a configurable default thread", func() {
913
defaults := config.NewStore().ReadDefaults()
914
915
// Initial check for default thread
916
output := runCommand("--config")
917
Expect(output).To(ContainSubstring(defaults.Thread))
918
919
// Update and verify thread
920
newThread := "new-thread"
921
runCommand("--set-thread", newThread)
922
Expect(configFile).To(BeAnExistingFile())
923
checkConfigFileContent(newThread)
924
925
// Verify update through --config
926
output = runCommand("--config")
927
Expect(output).To(ContainSubstring(newThread))
928
929
// Environment variable takes precedence
930
threadEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "THREAD", 1)
931
Expect(os.Setenv(threadEnvKey, newThread)).To(Succeed())
932
933
// Verify environment variable override
934
output = runCommand("--config")
935
Expect(output).To(ContainSubstring(newThread))
936
Expect(os.Unsetenv(threadEnvKey)).To(Succeed())
937
})
938
})
939
})
940
941
when("configuration precedence", func() {
942
var (
943
defaultModel = "gpt-4o"
944
newModel = "gpt-3.5-turbo-0301"
945
envModel = "gpt-3.5-env-model"
946
envVar string
947
)
948
949
it.Before(func() {
950
envVar = strings.Replace(apiKeyEnvVar, "API_KEY", "MODEL", 1)
951
filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")
952
Expect(os.MkdirAll(filePath, 0777)).To(Succeed())
953
954
configFile = path.Join(filePath, "config.yaml")
955
Expect(configFile).NotTo(BeAnExistingFile())
956
})
957
958
it("uses environment variable over config file", func() {
959
// Step 1: Set a model in the config file.
960
runCommand("--set-model", newModel)
961
checkConfigFileContent(newModel)
962
963
// Step 2: Verify the model from config is used.
964
output := runCommand("--list-models")
965
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
966
967
// Step 3: Set environment variable and verify it takes precedence.
968
Expect(os.Setenv(envVar, envModel)).To(Succeed())
969
output = runCommand("--list-models")
970
Expect(output).To(ContainSubstring("* " + envModel + " (current)"))
971
972
// Step 4: Unset environment variable and verify it falls back to config file.
973
Expect(os.Unsetenv(envVar)).To(Succeed())
974
output = runCommand("--list-models")
975
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
976
})
977
978
it("uses command-line flag over environment variable", func() {
979
// Step 1: Set environment variable.
980
Expect(os.Setenv(envVar, envModel)).To(Succeed())
981
982
// Step 2: Verify environment variable does not override flag.
983
output := runCommand("--model", newModel, "--list-models")
984
Expect(output).To(ContainSubstring("* " + newModel + " (current)"))
985
})
986
987
it("falls back to default when config and env are absent", func() {
988
// Step 1: Ensure no config file and no environment variable.
989
Expect(os.Unsetenv(envVar)).To(Succeed())
990
991
// Step 2: Verify it falls back to the default model.
992
output := runCommand("--list-models")
993
Expect(output).To(ContainSubstring("* " + defaultModel + " (current)"))
994
})
995
})
996
997
when("show-history flag is used", func() {
998
var tmpDir string
999
var err error
1000
var historyFile string
1001
1002
it.Before(func() {
1003
RegisterTestingT(t)
1004
tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")
1005
Expect(err).NotTo(HaveOccurred())
1006
historyFile = filepath.Join(tmpDir, "default.json")
1007
1008
messages := []api.Message{
1009
{Role: "user", Content: "Hello"},
1010
{Role: "assistant", Content: "Hi, how can I help you?"},
1011
{Role: "user", Content: "Tell me about the weather"},
1012
{Role: "assistant", Content: "It's sunny today."},
1013
}
1014
data, err := json.Marshal(messages)
1015
Expect(err).NotTo(HaveOccurred())
1016
1017
Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())
1018
1019
// This is legacy: we need a config dir in order to have a history dir
1020
filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")
1021
Expect(os.MkdirAll(filePath, 0777)).To(Succeed())
1022
1023
Expect(os.Setenv("OPENAI_DATA_HOME", tmpDir)).To(Succeed())
1024
})
1025
1026
it("prints the history for the default thread", func() {
1027
output := runCommand("--show-history")
1028
1029
// Check that the output contains the history as expected
1030
Expect(output).To(ContainSubstring("**USER** 👤:\nHello"))
1031
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nHi, how can I help you?"))
1032
Expect(output).To(ContainSubstring("**USER** 👤:\nTell me about the weather"))
1033
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nIt's sunny today."))
1034
})
1035
1036
it("prints the history for a specific thread when specified", func() {
1037
specificThread := "specific-thread"
1038
specificHistoryFile := filepath.Join(tmpDir, specificThread+".json")
1039
1040
// Create a specific thread with custom history
1041
messages := []api.Message{
1042
{Role: "user", Content: "What's the capital of Belgium?"},
1043
{Role: "assistant", Content: "The capital of Belgium is Brussels."},
1044
}
1045
data, err := json.Marshal(messages)
1046
Expect(err).NotTo(HaveOccurred())
1047
Expect(os.WriteFile(specificHistoryFile, data, 0644)).To(Succeed())
1048
1049
// Run the --show-history flag with the specific thread
1050
output := runCommand("--show-history", specificThread)
1051
1052
// Check that the output contains the history as expected
1053
Expect(output).To(ContainSubstring("**USER** 👤:\nWhat's the capital of Belgium?"))
1054
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThe capital of Belgium is Brussels."))
1055
})
1056
1057
it("concatenates user messages correctly", func() {
1058
// Create history where two user messages are concatenated
1059
messages := []api.Message{
1060
{Role: "user", Content: "Part one"},
1061
{Role: "user", Content: " and part two"},
1062
{Role: "assistant", Content: "This is a response."},
1063
}
1064
data, err := json.Marshal(messages)
1065
Expect(err).NotTo(HaveOccurred())
1066
Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())
1067
1068
output := runCommand("--show-history")
1069
1070
// Check that the concatenated user messages are displayed correctly
1071
Expect(output).To(ContainSubstring("**USER** 👤:\nPart one and part two"))
1072
Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThis is a response."))
1073
})
1074
})
1075
})
1076
}
1077
1078