Path: blob/main/test/integration/integration_test.go
2867 views
package integration_test12import (3"encoding/json"4"fmt"5"github.com/kardolus/chatgpt-cli/api"6"github.com/kardolus/chatgpt-cli/config"7"github.com/kardolus/chatgpt-cli/history"8"github.com/kardolus/chatgpt-cli/internal"9"github.com/kardolus/chatgpt-cli/test"10"github.com/onsi/gomega/gexec"11"github.com/sclevine/spec"12"github.com/sclevine/spec/report"13"io"14"log"15"os"16"os/exec"17"path"18"path/filepath"19"strconv"20"strings"21"sync"22"testing"23"time"2425. "github.com/onsi/gomega"26)2728const (29gitCommit = "some-git-commit"30gitVersion = "some-git-version"31servicePort = ":8080"32serviceURL = "http://0.0.0.0" + servicePort33)3435var (36once sync.Once37)3839func TestIntegration(t *testing.T) {40defer gexec.CleanupBuildArtifacts()41spec.Run(t, "Integration Tests", testIntegration, spec.Report(report.Terminal{}))42}4344func testIntegration(t *testing.T, when spec.G, it spec.S) {45it.Before(func() {46RegisterTestingT(t)47Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())48Expect(os.Unsetenv(internal.DataHomeEnv)).To(Succeed())49})5051when("Read and Write History", func() {52const threadName = "default-thread"5354var (55tmpDir string56fileIO *history.FileIO57messages []api.Message58err error59)6061it.Before(func() {62tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")63Expect(err).NotTo(HaveOccurred())6465fileIO, _ = history.New()66fileIO = fileIO.WithDirectory(tmpDir)67fileIO.SetThread(threadName)6869messages = []api.Message{70{71Role: "user",72Content: "Test message 1",73},74{75Role: "assistant",76Content: "Test message 2",77},78}79})8081it.After(func() {82Expect(os.RemoveAll(tmpDir)).To(Succeed())83})8485it("writes the messages to the file", func() {86var historyEntries []history.History87for _, message := range messages {88historyEntries = append(historyEntries, history.History{89Message: message,90})91}9293err = fileIO.Write(historyEntries)94Expect(err).NotTo(HaveOccurred())95})9697it("reads the messages from the file", func() {98var historyEntries []history.History99for _, message := range messages {100historyEntries = append(historyEntries, history.History{101Message: message,102})103}104105err = fileIO.Write(historyEntries) // need to write before reading106Expect(err).NotTo(HaveOccurred())107108readEntries, err := fileIO.Read()109Expect(err).NotTo(HaveOccurred())110Expect(readEntries).To(Equal(historyEntries))111})112})113114when("Read, Write, List, Delete Config", func() {115var (116tmpDir string117tmpFile *os.File118historyDir string119configIO *config.FileIO120testConfig config.Config121err error122)123124it.Before(func() {125tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")126Expect(err).NotTo(HaveOccurred())127128historyDir, err = os.MkdirTemp(tmpDir, "history")129Expect(err).NotTo(HaveOccurred())130131tmpFile, err = os.CreateTemp(tmpDir, "config.yaml")132Expect(err).NotTo(HaveOccurred())133134Expect(tmpFile.Close()).To(Succeed())135136configIO = config.NewStore().WithConfigPath(tmpFile.Name()).WithHistoryPath(historyDir)137138testConfig = config.Config{139Model: "test-model",140}141})142143it.After(func() {144Expect(os.RemoveAll(tmpDir)).To(Succeed())145})146147when("performing a migration", func() {148defaults := config.NewStore().ReadDefaults()149150it("it doesn't apply a migration when max_tokens is 0", func() {151testConfig.MaxTokens = 0152153err = configIO.Write(testConfig) // need to write before reading154Expect(err).NotTo(HaveOccurred())155156readConfig, err := configIO.Read()157Expect(err).NotTo(HaveOccurred())158Expect(readConfig).To(Equal(testConfig))159})160it("it migrates small values of max_tokens as expected", func() {161testConfig.MaxTokens = defaults.ContextWindow - 1162163err = configIO.Write(testConfig) // need to write before reading164Expect(err).NotTo(HaveOccurred())165166readConfig, err := configIO.Read()167Expect(err).NotTo(HaveOccurred())168169expectedConfig := testConfig170expectedConfig.MaxTokens = defaults.MaxTokens171expectedConfig.ContextWindow = defaults.ContextWindow172173Expect(readConfig).To(Equal(expectedConfig))174})175it("it migrates large values of max_tokens as expected", func() {176testConfig.MaxTokens = defaults.ContextWindow + 1177178err = configIO.Write(testConfig) // need to write before reading179Expect(err).NotTo(HaveOccurred())180181readConfig, err := configIO.Read()182Expect(err).NotTo(HaveOccurred())183184expectedConfig := testConfig185expectedConfig.MaxTokens = defaults.MaxTokens186expectedConfig.ContextWindow = testConfig.MaxTokens187188Expect(readConfig).To(Equal(expectedConfig))189})190})191192it("lists all the threads", func() {193files := []string{"thread1.json", "thread2.json", "thread3.json"}194195for _, file := range files {196file, err := os.Create(filepath.Join(historyDir, file))197Expect(err).NotTo(HaveOccurred())198199Expect(file.Close()).To(Succeed())200}201202result, err := configIO.List()203Expect(err).NotTo(HaveOccurred())204Expect(result).To(HaveLen(3))205Expect(result[2]).To(Equal("thread3.json"))206})207208it("deletes the thread", func() {209files := []string{"thread1.json", "thread2.json", "thread3.json"}210211for _, file := range files {212file, err := os.Create(filepath.Join(historyDir, file))213Expect(err).NotTo(HaveOccurred())214215Expect(file.Close()).To(Succeed())216}217218err = configIO.Delete("thread2")219Expect(err).NotTo(HaveOccurred())220221_, err = os.Stat(filepath.Join(historyDir, "thread2.json"))222Expect(os.IsNotExist(err)).To(BeTrue())223224_, err = os.Stat(filepath.Join(historyDir, "thread3.json"))225Expect(os.IsNotExist(err)).To(BeFalse())226})227})228229when("Performing the Lifecycle", func() {230const (231exitSuccess = 0232exitFailure = 1233)234235var (236homeDir string237filePath string238configFile string239err error240apiKeyEnvVar string241)242243runCommand := func(args ...string) string {244command := exec.Command(binaryPath, args...)245session, err := gexec.Start(command, io.Discard, io.Discard)246247ExpectWithOffset(1, err).NotTo(HaveOccurred())248<-session.Exited249250if tmp := string(session.Err.Contents()); tmp != "" {251fmt.Printf("error output: %s", string(session.Err.Contents()))252}253254ExpectWithOffset(1, session).Should(gexec.Exit(0))255return string(session.Out.Contents())256}257258runCommandWithStdin := func(stdin io.Reader, args ...string) string {259command := exec.Command(binaryPath, args...)260command.Stdin = stdin261session, err := gexec.Start(command, io.Discard, io.Discard)262263ExpectWithOffset(1, err).NotTo(HaveOccurred())264<-session.Exited265266if tmp := string(session.Err.Contents()); tmp != "" {267fmt.Printf("error output: %s", tmp)268}269270ExpectWithOffset(1, session).Should(gexec.Exit(0))271return string(session.Out.Contents())272}273274checkConfigFileContent := func(expectedContent string) {275content, err := os.ReadFile(configFile)276ExpectWithOffset(1, err).NotTo(HaveOccurred())277ExpectWithOffset(1, string(content)).To(ContainSubstring(expectedContent))278}279280it.Before(func() {281once.Do(func() {282SetDefaultEventuallyTimeout(10 * time.Second)283284log.Println("Building binary...")285Expect(buildBinary()).To(Succeed())286log.Println("Binary built successfully!")287288log.Println("Starting mock server...")289Expect(runMockServer()).To(Succeed())290log.Println("Mock server started!")291292Eventually(func() (string, error) {293return curl(fmt.Sprintf("%s/ping", serviceURL))294}).Should(ContainSubstring("pong"))295})296297homeDir, err = os.MkdirTemp("", "mockHome")298Expect(err).NotTo(HaveOccurred())299300apiKeyEnvVar = config.NewManager(config.NewStore()).WithEnvironment().APIKeyEnvVarName()301302Expect(os.Setenv("HOME", homeDir)).To(Succeed())303Expect(os.Setenv(apiKeyEnvVar, expectedToken)).To(Succeed())304})305306it.After(func() {307gexec.Kill()308Expect(os.RemoveAll(homeDir))309})310311when("resolving the API key", func() {312var secretFile string313314it.Before(func() {315secretFile = filepath.Join(homeDir, ".chatgpt-cli", "secret.key")316Expect(os.MkdirAll(filepath.Dir(secretFile), 0700)).To(Succeed())317Expect(os.WriteFile(secretFile, []byte(expectedToken+"\n"), 0600)).To(Succeed())318})319320it.After(func() {321Expect(os.RemoveAll(filepath.Dir(secretFile))).To(Succeed())322Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())323Expect(os.Unsetenv("OPENAI_API_KEY_FILE")).To(Succeed())324})325326it("prefers the API key from environment variable over the file", func() {327Expect(os.Setenv(apiKeyEnvVar, "env-api-key")).To(Succeed())328Expect(os.Setenv("OPENAI_API_KEY_FILE", secretFile)).To(Succeed())329330cmd := exec.Command(binaryPath, "--config")331session, err := gexec.Start(cmd, io.Discard, io.Discard)332Expect(err).NotTo(HaveOccurred())333Eventually(session).Should(gexec.Exit(exitSuccess))334output := string(session.Out.Contents())335Expect(output).To(ContainSubstring("env-api-key"))336})337338it("uses the file if env var is not set", func() {339Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())340Expect(os.Setenv("OPENAI_API_KEY_FILE", secretFile)).To(Succeed())341342cmd := exec.Command(binaryPath, "--list-models")343session, err := gexec.Start(cmd, io.Discard, io.Discard)344Expect(err).NotTo(HaveOccurred())345Eventually(session).Should(gexec.Exit(exitSuccess))346})347348it("errors if neither env var nor file is set", func() {349Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())350Expect(os.Unsetenv("OPENAI_API_KEY_FILE")).To(Succeed())351352cmd := exec.Command(binaryPath, "--list-models")353session, err := gexec.Start(cmd, io.Discard, io.Discard)354Expect(err).NotTo(HaveOccurred())355Eventually(session).Should(gexec.Exit(exitFailure))356errOutput := string(session.Err.Contents())357Expect(errOutput).To(ContainSubstring("API key is required"))358})359})360361it("should not require an API key for the --version flag", func() {362Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())363364command := exec.Command(binaryPath, "--version")365session, err := gexec.Start(command, io.Discard, io.Discard)366Expect(err).NotTo(HaveOccurred())367368Eventually(session).Should(gexec.Exit(exitSuccess))369})370371it("should require a hidden folder for the --list-threads flag", func() {372command := exec.Command(binaryPath, "--list-threads")373session, err := gexec.Start(command, io.Discard, io.Discard)374Expect(err).NotTo(HaveOccurred())375376Eventually(session).Should(gexec.Exit(exitFailure))377378output := string(session.Err.Contents())379Expect(output).To(ContainSubstring(".chatgpt-cli/history: no such file or directory"))380})381382it("should return an error when --new-thread is used with --set-thread", func() {383command := exec.Command(binaryPath, "--new-thread", "--set-thread", "some-thread")384session, err := gexec.Start(command, io.Discard, io.Discard)385Expect(err).NotTo(HaveOccurred())386387Eventually(session).Should(gexec.Exit(exitFailure))388389output := string(session.Err.Contents())390Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))391})392393it("should return an error when --new-thread is used with --thread", func() {394command := exec.Command(binaryPath, "--new-thread", "--thread", "some-thread")395session, err := gexec.Start(command, io.Discard, io.Discard)396Expect(err).NotTo(HaveOccurred())397398Eventually(session).Should(gexec.Exit(exitFailure))399400output := string(session.Err.Contents())401Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))402})403404it("should require an argument for the --set-model flag", func() {405command := exec.Command(binaryPath, "--set-model")406session, err := gexec.Start(command, io.Discard, io.Discard)407Expect(err).NotTo(HaveOccurred())408409Eventually(session).Should(gexec.Exit(exitFailure))410411output := string(session.Err.Contents())412Expect(output).To(ContainSubstring("flag needs an argument: --set-model"))413})414415it("should require an argument for the --set-thread flag", func() {416command := exec.Command(binaryPath, "--set-thread")417session, err := gexec.Start(command, io.Discard, io.Discard)418Expect(err).NotTo(HaveOccurred())419420Eventually(session).Should(gexec.Exit(exitFailure))421422output := string(session.Err.Contents())423Expect(output).To(ContainSubstring("flag needs an argument: --set-thread"))424})425426it("should require an argument for the --set-max-tokens flag", func() {427command := exec.Command(binaryPath, "--set-max-tokens")428session, err := gexec.Start(command, io.Discard, io.Discard)429Expect(err).NotTo(HaveOccurred())430431Eventually(session).Should(gexec.Exit(exitFailure))432433output := string(session.Err.Contents())434Expect(output).To(ContainSubstring("flag needs an argument: --set-max-tokens"))435})436437it("should require an argument for the --set-context-window flag", func() {438command := exec.Command(binaryPath, "--set-context-window")439session, err := gexec.Start(command, io.Discard, io.Discard)440Expect(err).NotTo(HaveOccurred())441442Eventually(session).Should(gexec.Exit(exitFailure))443444output := string(session.Err.Contents())445Expect(output).To(ContainSubstring("flag needs an argument: --set-context-window"))446})447448it("should warn when config.yaml does not exist and OPENAI_CONFIG_HOME is set", func() {449configHomeDir := "does-not-exist"450Expect(os.Setenv(internal.ConfigHomeEnv, configHomeDir)).To(Succeed())451452configFilePath := path.Join(configHomeDir, "config.yaml")453Expect(configFilePath).NotTo(BeAnExistingFile())454455command := exec.Command(binaryPath, "llm query")456session, err := gexec.Start(command, io.Discard, io.Discard)457Expect(err).NotTo(HaveOccurred())458459Eventually(session).Should(gexec.Exit(exitSuccess))460461output := string(session.Err.Contents())462Expect(output).To(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))463464// Unset the variable to prevent pollution465Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())466})467468it("should NOT warn when config.yaml does not exist and OPENAI_CONFIG_HOME is NOT set", func() {469configHomeDir := "does-not-exist"470Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())471472configFilePath := path.Join(configHomeDir, "config.yaml")473Expect(configFilePath).NotTo(BeAnExistingFile())474475command := exec.Command(binaryPath, "llm query")476session, err := gexec.Start(command, io.Discard, io.Discard)477Expect(err).NotTo(HaveOccurred())478479Eventually(session).Should(gexec.Exit(exitSuccess))480481output := string(session.Out.Contents())482Expect(output).NotTo(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))483})484485it("should require the chatgpt-cli folder but not an API key for the --set-model flag", func() {486Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())487488command := exec.Command(binaryPath, "--set-model", "123")489session, err := gexec.Start(command, io.Discard, io.Discard)490Expect(err).NotTo(HaveOccurred())491492Eventually(session).Should(gexec.Exit(exitFailure))493494output := string(session.Err.Contents())495Expect(output).To(ContainSubstring("config directory does not exist:"))496Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))497})498499it("should require the chatgpt-cli folder but not an API key for the --set-thread flag", func() {500Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())501502command := exec.Command(binaryPath, "--set-thread", "thread-name")503session, err := gexec.Start(command, io.Discard, io.Discard)504Expect(err).NotTo(HaveOccurred())505506Eventually(session).Should(gexec.Exit(exitFailure))507508output := string(session.Err.Contents())509Expect(output).To(ContainSubstring("config directory does not exist:"))510Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))511})512513it("should require the chatgpt-cli folder but not an API key for the --set-max-tokens flag", func() {514Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())515516command := exec.Command(binaryPath, "--set-max-tokens", "789")517session, err := gexec.Start(command, io.Discard, io.Discard)518Expect(err).NotTo(HaveOccurred())519520Eventually(session).Should(gexec.Exit(exitFailure))521522output := string(session.Err.Contents())523Expect(output).To(ContainSubstring("config directory does not exist:"))524Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))525})526527it("should require the chatgpt-cli folder but not an API key for the --set-context-window flag", func() {528Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())529530command := exec.Command(binaryPath, "--set-context-window", "789")531session, err := gexec.Start(command, io.Discard, io.Discard)532Expect(err).NotTo(HaveOccurred())533534Eventually(session).Should(gexec.Exit(exitFailure))535536output := string(session.Err.Contents())537Expect(output).To(ContainSubstring("config directory does not exist:"))538Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))539})540541it("should return the expected result for the --version flag", func() {542output := runCommand("--version")543544Expect(output).To(ContainSubstring(fmt.Sprintf("commit %s", gitCommit)))545Expect(output).To(ContainSubstring(fmt.Sprintf("version %s", gitVersion)))546})547548it("should return the expected result for the --list-models flag", func() {549output := runCommand("--list-models")550551Expect(output).To(ContainSubstring("* gpt-4o (current)"))552Expect(output).To(ContainSubstring("- gpt-3.5-turbo"))553Expect(output).To(ContainSubstring("- gpt-3.5-turbo-0301"))554})555556it("should return the expected result for the --query flag", func() {557Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "false")).To(Succeed())558559output := runCommand("--query", "some-query")560561expectedResponse := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`562Expect(output).To(ContainSubstring(expectedResponse))563Expect(output).NotTo(ContainSubstring("Token Usage:"))564})565566it("should display token usage after a query when configured to do so", func() {567Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "true")).To(Succeed())568569output := runCommand("--query", "tell me a 5 line joke")570Expect(output).To(ContainSubstring("Token Usage:"))571})572573it("prints debug information with the --debug flag", func() {574output := runCommand("--query", "tell me a joke", "--debug")575576Expect(output).To(ContainSubstring("Generated cURL command"))577Expect(output).To(ContainSubstring("/v1/chat/completions"))578Expect(output).To(ContainSubstring("--header \"Authorization: Bearer ${OPENAI_API_KEY}\""))579Expect(output).To(ContainSubstring("--header 'Content-Type: application/json'"))580Expect(output).To(ContainSubstring("--header 'User-Agent: chatgpt-cli'"))581Expect(output).To(ContainSubstring("\"model\":\"gpt-4o\""))582Expect(output).To(ContainSubstring("\"messages\":"))583Expect(output).To(ContainSubstring("Response"))584585Expect(os.Unsetenv("OPENAI_DEBUG")).To(Succeed())586})587588it("should assemble http errors as expected", func() {589Expect(os.Setenv(apiKeyEnvVar, "wrong-token")).To(Succeed())590591command := exec.Command(binaryPath, "--query", "some-query")592session, err := gexec.Start(command, io.Discard, io.Discard)593Expect(err).NotTo(HaveOccurred())594595Eventually(session).Should(gexec.Exit(exitFailure))596597output := string(session.Err.Contents())598599// see error.json600Expect(output).To(Equal("http status 401: Incorrect API key provided\n"))601})602603when("loading configuration via --target", func() {604var (605configDir string606mainConfig string607targetConfig string608)609610it.Before(func() {611RegisterTestingT(t)612613var err error614configDir, err = os.MkdirTemp("", "chatgpt-cli-test")615Expect(err).NotTo(HaveOccurred())616617Expect(os.Setenv("OPENAI_CONFIG_HOME", configDir)).To(Succeed())618619mainConfig = filepath.Join(configDir, "config.yaml")620targetConfig = filepath.Join(configDir, "config.testtarget.yaml")621622Expect(os.WriteFile(mainConfig, []byte("model: gpt-4o\n"), 0644)).To(Succeed())623Expect(os.WriteFile(targetConfig, []byte("model: gpt-3.5-turbo-0301\n"), 0644)).To(Succeed())624})625626it("should load config.testtarget.yaml when using --target", func() {627cmd := exec.Command(binaryPath, "--target", "testtarget", "--config")628629session, err := gexec.Start(cmd, io.Discard, io.Discard)630Expect(err).NotTo(HaveOccurred())631632Eventually(session).Should(gexec.Exit(0))633output := string(session.Out.Contents())634Expect(output).To(ContainSubstring("gpt-3.5-turbo-0301"))635})636637it("should fall back to config.yaml when --target is not used", func() {638cmd := exec.Command(binaryPath, "--config")639640session, err := gexec.Start(cmd, io.Discard, io.Discard)641Expect(err).NotTo(HaveOccurred())642643Eventually(session).Should(gexec.Exit(0))644output := string(session.Out.Contents())645Expect(output).To(ContainSubstring("gpt-4o"))646})647})648649when("there is a hidden chatgpt-cli folder in the home dir", func() {650it.Before(func() {651filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")652Expect(os.MkdirAll(filePath, 0777)).To(Succeed())653})654655it.After(func() {656Expect(os.RemoveAll(filePath)).To(Succeed())657})658659it("should not require an API key for the --list-threads flag", func() {660historyPath := path.Join(filePath, "history")661Expect(os.MkdirAll(historyPath, 0777)).To(Succeed())662663Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())664665command := exec.Command(binaryPath, "--list-threads")666session, err := gexec.Start(command, io.Discard, io.Discard)667Expect(err).NotTo(HaveOccurred())668669Eventually(session).Should(gexec.Exit(exitSuccess))670})671672it("migrates the legacy history as expected", func() {673// Legacy history file should not exist674legacyFile := path.Join(filePath, "history")675Expect(legacyFile).NotTo(BeAnExistingFile())676677// History should not exist yet678historyFile := path.Join(filePath, "history", "default.json")679Expect(historyFile).NotTo(BeAnExistingFile())680681bytes, err := test.FileToBytes("history.json")682Expect(err).NotTo(HaveOccurred())683684Expect(os.WriteFile(legacyFile, bytes, 0644)).To(Succeed())685Expect(legacyFile).To(BeARegularFile())686687// Perform a query688command := exec.Command(binaryPath, "--query", "some-query")689session, err := gexec.Start(command, io.Discard, io.Discard)690Expect(err).NotTo(HaveOccurred())691692// The CLI response should be as expected693Eventually(session).Should(gexec.Exit(exitSuccess))694695output := string(session.Out.Contents())696697response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`698Expect(output).To(ContainSubstring(response))699700// The history file should have the expected content701Expect(path.Dir(historyFile)).To(BeADirectory())702content, err := os.ReadFile(historyFile)703704Expect(err).NotTo(HaveOccurred())705Expect(content).NotTo(BeEmpty())706Expect(string(content)).To(ContainSubstring(response))707708// The legacy file should now be a directory709Expect(legacyFile).To(BeADirectory())710Expect(legacyFile).NotTo(BeARegularFile())711712// The content was moved to the new file713Expect(string(content)).To(ContainSubstring("Of course! Which city are you referring to?"))714})715716it("should not require an API key for the --clear-history flag", func() {717Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())718719command := exec.Command(binaryPath, "--clear-history")720session, err := gexec.Start(command, io.Discard, io.Discard)721Expect(err).NotTo(HaveOccurred())722723Eventually(session).Should(gexec.Exit(exitSuccess))724})725726it("keeps track of history", func() {727// History should not exist yet728historyDir := path.Join(filePath, "history")729historyFile := path.Join(historyDir, "default.json")730Expect(historyFile).NotTo(BeAnExistingFile())731732// Perform a query and check response733response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`734output := runCommand("--query", "some-query")735Expect(output).To(ContainSubstring(response))736737// Check if history file was created with expected content738Expect(historyDir).To(BeADirectory())739checkHistoryContent := func(expectedContent string) {740content, err := os.ReadFile(historyFile)741Expect(err).NotTo(HaveOccurred())742Expect(string(content)).To(ContainSubstring(expectedContent))743}744checkHistoryContent(response)745746// Clear the history using the CLI747runCommand("--clear-history")748Expect(historyFile).NotTo(BeAnExistingFile())749750// Test omitting history through environment variable751omitHistoryEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "OMIT_HISTORY", 1)752envValue := "true"753Expect(os.Setenv(omitHistoryEnvKey, envValue)).To(Succeed())754755// Perform another query with history omitted756runCommand("--query", "some-query")757// The history file should NOT be recreated758Expect(historyFile).NotTo(BeAnExistingFile())759760// Cleanup: Unset the environment variable761Expect(os.Unsetenv(omitHistoryEnvKey)).To(Succeed())762})763764it("should not add binary data to the history", func() {765historyDir := path.Join(filePath, "history")766historyFile := path.Join(historyDir, "default.json")767Expect(historyFile).NotTo(BeAnExistingFile())768769response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`770771// Create a pipe to simulate binary input772r, w := io.Pipe()773defer r.Close()774775// Run the command with piped binary input776binaryData := []byte{0x00, 0xFF, 0x42, 0x10}777go func() {778defer w.Close()779_, err := w.Write(binaryData)780Expect(err).NotTo(HaveOccurred())781}()782783// Run the command with stdin redirected784output := runCommandWithStdin(r, "--query", "some-query")785Expect(output).To(ContainSubstring(response))786787Expect(historyDir).To(BeADirectory())788checkHistoryContent := func(expectedContent string) {789content, err := os.ReadFile(historyFile)790Expect(err).NotTo(HaveOccurred())791Expect(string(content)).To(ContainSubstring(expectedContent))792}793checkHistoryContent(response)794})795796it("should return the expected result for the --list-threads flag", func() {797historyDir := path.Join(filePath, "history")798Expect(os.Mkdir(historyDir, 0755)).To(Succeed())799800files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}801802Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())803804for _, file := range files {805file, err := os.Create(filepath.Join(historyDir, file))806Expect(err).NotTo(HaveOccurred())807808Expect(file.Close()).To(Succeed())809}810811output := runCommand("--list-threads")812813Expect(output).To(ContainSubstring("* default (current)"))814Expect(output).To(ContainSubstring("- thread1"))815Expect(output).To(ContainSubstring("- thread2"))816Expect(output).To(ContainSubstring("- thread3"))817})818819it("should delete the expected thread using the --delete-threads flag", func() {820historyDir := path.Join(filePath, "history")821Expect(os.Mkdir(historyDir, 0755)).To(Succeed())822823files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}824825Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())826827for _, file := range files {828file, err := os.Create(filepath.Join(historyDir, file))829Expect(err).NotTo(HaveOccurred())830831Expect(file.Close()).To(Succeed())832}833834runCommand("--delete-thread", "thread2")835836output := runCommand("--list-threads")837838Expect(output).To(ContainSubstring("* default (current)"))839Expect(output).To(ContainSubstring("- thread1"))840Expect(output).NotTo(ContainSubstring("- thread2"))841Expect(output).To(ContainSubstring("- thread3"))842})843844it("should delete the expected threads using the --delete-threads flag with a wildcard", func() {845historyDir := filepath.Join(filePath, "history")846Expect(os.Mkdir(historyDir, 0755)).To(Succeed())847848files := []string{849"start1.json", "start2.json", "start3.json",850"1end.json", "2end.json", "3end.json",851"1middle1.json", "2middle2.json", "3middle3.json",852"other1.json", "other2.json",853}854855createTestFiles := func(dir string, filenames []string) {856for _, filename := range filenames {857file, err := os.Create(filepath.Join(dir, filename))858Expect(err).NotTo(HaveOccurred())859Expect(file.Close()).To(Succeed())860}861}862863createTestFiles(historyDir, files)864865output := runCommand("--list-threads")866expectedThreads := []string{867"start1", "start2", "start3",868"1end", "2end", "3end",869"1middle1", "2middle2", "3middle3",870"other1", "other2",871}872for _, thread := range expectedThreads {873Expect(output).To(ContainSubstring("- " + thread))874}875876tests := []struct {877pattern string878remainingAfter []string879}{880{"start*", []string{"1end", "2end", "3end", "1middle1", "2middle2", "3middle3", "other1", "other2"}},881{"*end", []string{"1middle1", "2middle2", "3middle3", "other1", "other2"}},882{"*middle*", []string{"other1", "other2"}},883{"*", []string{}}, // Should delete all remaining threads884}885886for _, tt := range tests {887runCommand("--delete-thread", tt.pattern)888output = runCommand("--list-threads")889890for _, thread := range tt.remainingAfter {891Expect(output).To(ContainSubstring("- " + thread))892}893}894})895896it("should throw an error when a non-existent thread is deleted using the --delete-threads flag", func() {897command := exec.Command(binaryPath, "--delete-thread", "does-not-exist")898session, err := gexec.Start(command, io.Discard, io.Discard)899Expect(err).NotTo(HaveOccurred())900901Eventually(session).Should(gexec.Exit(exitFailure))902})903904it("should not throw an error --clear-history is called without there being a history", func() {905command := exec.Command(binaryPath, "--clear-history")906session, err := gexec.Start(command, io.Discard, io.Discard)907Expect(err).NotTo(HaveOccurred())908909Eventually(session).Should(gexec.Exit(exitSuccess))910})911912when("configurable flags are set", func() {913it.Before(func() {914configFile = path.Join(filePath, "config.yaml")915Expect(configFile).NotTo(BeAnExistingFile())916})917918it("has a configurable default model", func() {919oldModel := "gpt-4o"920newModel := "gpt-3.5-turbo-0301"921922// Verify initial model923output := runCommand("--list-models")924Expect(output).To(ContainSubstring("* " + oldModel + " (current)"))925Expect(output).To(ContainSubstring("- " + newModel))926927// Update model928runCommand("--set-model", newModel)929930// Check configFile is created and contains the new model931Expect(configFile).To(BeAnExistingFile())932checkConfigFileContent(newModel)933934// Verify updated model through --list-models935output = runCommand("--list-models")936937Expect(output).To(ContainSubstring("* " + newModel + " (current)"))938})939940it("has a configurable default context-window", func() {941defaults := config.NewStore().ReadDefaults()942943// Initial check for default context-window944output := runCommand("--config")945Expect(output).To(ContainSubstring(strconv.Itoa(defaults.ContextWindow)))946947// Update and verify context-window948newContextWindow := "100000"949runCommand("--set-context-window", newContextWindow)950Expect(configFile).To(BeAnExistingFile())951checkConfigFileContent(newContextWindow)952953// Verify update through --config954output = runCommand("--config")955Expect(output).To(ContainSubstring(newContextWindow))956957// Environment variable takes precedence958envContext := "123"959modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "CONTEXT_WINDOW", 1)960Expect(os.Setenv(modelEnvKey, envContext)).To(Succeed())961962// Verify environment variable override963output = runCommand("--config")964Expect(output).To(ContainSubstring(envContext))965Expect(os.Unsetenv(modelEnvKey)).To(Succeed())966})967968it("has a configurable default max-tokens", func() {969defaults := config.NewStore().ReadDefaults()970971// Initial check for default max-tokens972output := runCommand("--config")973Expect(output).To(ContainSubstring(strconv.Itoa(defaults.MaxTokens)))974975// Update and verify max-tokens976newMaxTokens := "81724"977runCommand("--set-max-tokens", newMaxTokens)978Expect(configFile).To(BeAnExistingFile())979checkConfigFileContent(newMaxTokens)980981// Verify update through --config982output = runCommand("--config")983Expect(output).To(ContainSubstring(newMaxTokens))984985// Environment variable takes precedence986modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "MAX_TOKENS", 1)987Expect(os.Setenv(modelEnvKey, newMaxTokens)).To(Succeed())988989// Verify environment variable override990output = runCommand("--config")991Expect(output).To(ContainSubstring(newMaxTokens))992Expect(os.Unsetenv(modelEnvKey)).To(Succeed())993})994995it("has a configurable default thread", func() {996defaults := config.NewStore().ReadDefaults()997998// Initial check for default thread999output := runCommand("--config")1000Expect(output).To(ContainSubstring(defaults.Thread))10011002// Update and verify thread1003newThread := "new-thread"1004runCommand("--set-thread", newThread)1005Expect(configFile).To(BeAnExistingFile())1006checkConfigFileContent(newThread)10071008// Verify update through --config1009output = runCommand("--config")1010Expect(output).To(ContainSubstring(newThread))10111012// Environment variable takes precedence1013threadEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "THREAD", 1)1014Expect(os.Setenv(threadEnvKey, newThread)).To(Succeed())10151016// Verify environment variable override1017output = runCommand("--config")1018Expect(output).To(ContainSubstring(newThread))1019Expect(os.Unsetenv(threadEnvKey)).To(Succeed())1020})1021})1022})10231024when("configuration precedence", func() {1025var (1026defaultModel = "gpt-4o"1027newModel = "gpt-3.5-turbo-0301"1028envModel = "gpt-3.5-env-model"1029envVar string1030)10311032it.Before(func() {1033envVar = strings.Replace(apiKeyEnvVar, "API_KEY", "MODEL", 1)1034filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")1035Expect(os.MkdirAll(filePath, 0777)).To(Succeed())10361037configFile = path.Join(filePath, "config.yaml")1038Expect(configFile).NotTo(BeAnExistingFile())1039})10401041it("uses environment variable over config file", func() {1042// Step 1: Set a model in the config file.1043runCommand("--set-model", newModel)1044checkConfigFileContent(newModel)10451046// Step 2: Verify the model from config is used.1047output := runCommand("--list-models")1048Expect(output).To(ContainSubstring("* " + newModel + " (current)"))10491050// Step 3: Set environment variable and verify it takes precedence.1051Expect(os.Setenv(envVar, envModel)).To(Succeed())1052output = runCommand("--list-models")1053Expect(output).To(ContainSubstring("* " + envModel + " (current)"))10541055// Step 4: Unset environment variable and verify it falls back to config file.1056Expect(os.Unsetenv(envVar)).To(Succeed())1057output = runCommand("--list-models")1058Expect(output).To(ContainSubstring("* " + newModel + " (current)"))1059})10601061it("uses command-line flag over environment variable", func() {1062// Step 1: Set environment variable.1063Expect(os.Setenv(envVar, envModel)).To(Succeed())10641065// Step 2: Verify environment variable does not override flag.1066output := runCommand("--model", newModel, "--list-models")1067Expect(output).To(ContainSubstring("* " + newModel + " (current)"))1068})10691070it("falls back to default when config and env are absent", func() {1071// Step 1: Ensure no config file and no environment variable.1072Expect(os.Unsetenv(envVar)).To(Succeed())10731074// Step 2: Verify it falls back to the default model.1075output := runCommand("--list-models")1076Expect(output).To(ContainSubstring("* " + defaultModel + " (current)"))1077})1078})10791080when("show-history flag is used", func() {1081var tmpDir string1082var err error1083var historyFile string10841085it.Before(func() {1086RegisterTestingT(t)1087tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")1088Expect(err).NotTo(HaveOccurred())1089historyFile = filepath.Join(tmpDir, "default.json")10901091messages := []api.Message{1092{Role: "user", Content: "Hello"},1093{Role: "assistant", Content: "Hi, how can I help you?"},1094{Role: "user", Content: "Tell me about the weather"},1095{Role: "assistant", Content: "It's sunny today."},1096}1097data, err := json.Marshal(messages)1098Expect(err).NotTo(HaveOccurred())10991100Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())11011102// This is legacy: we need a config dir in order to have a history dir1103filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")1104Expect(os.MkdirAll(filePath, 0777)).To(Succeed())11051106Expect(os.Setenv("OPENAI_DATA_HOME", tmpDir)).To(Succeed())1107})11081109it("prints the history for the default thread", func() {1110output := runCommand("--show-history")11111112// Check that the output contains the history as expected1113Expect(output).To(ContainSubstring("**USER** 👤:\nHello"))1114Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nHi, how can I help you?"))1115Expect(output).To(ContainSubstring("**USER** 👤:\nTell me about the weather"))1116Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nIt's sunny today."))1117})11181119it("prints the history for a specific thread when specified", func() {1120specificThread := "specific-thread"1121specificHistoryFile := filepath.Join(tmpDir, specificThread+".json")11221123// Create a specific thread with custom history1124messages := []api.Message{1125{Role: "user", Content: "What's the capital of Belgium?"},1126{Role: "assistant", Content: "The capital of Belgium is Brussels."},1127}1128data, err := json.Marshal(messages)1129Expect(err).NotTo(HaveOccurred())1130Expect(os.WriteFile(specificHistoryFile, data, 0644)).To(Succeed())11311132// Run the --show-history flag with the specific thread1133output := runCommand("--show-history", specificThread)11341135// Check that the output contains the history as expected1136Expect(output).To(ContainSubstring("**USER** 👤:\nWhat's the capital of Belgium?"))1137Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThe capital of Belgium is Brussels."))1138})11391140it("concatenates user messages correctly", func() {1141// Create history where two user messages are concatenated1142messages := []api.Message{1143{Role: "user", Content: "Part one"},1144{Role: "user", Content: " and part two"},1145{Role: "assistant", Content: "This is a response."},1146}1147data, err := json.Marshal(messages)1148Expect(err).NotTo(HaveOccurred())1149Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())11501151output := runCommand("--show-history")11521153// Check that the concatenated user messages are displayed correctly1154Expect(output).To(ContainSubstring("**USER** 👤:\nPart one and part two"))1155Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThis is a response."))1156})1157})1158})1159}116011611162