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