Path: blob/main/test/integration/integration_test.go
2649 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})310311it("throws an error when the API key is missing", func() {312Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())313314command := exec.Command(binaryPath, "some prompt")315session, err := gexec.Start(command, io.Discard, io.Discard)316Expect(err).NotTo(HaveOccurred())317318Eventually(session).Should(gexec.Exit(exitFailure))319320output := string(session.Err.Contents())321Expect(output).To(ContainSubstring("API key is required."))322})323324it("should not require an API key for the --version flag", func() {325Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())326327command := exec.Command(binaryPath, "--version")328session, err := gexec.Start(command, io.Discard, io.Discard)329Expect(err).NotTo(HaveOccurred())330331Eventually(session).Should(gexec.Exit(exitSuccess))332})333334it("should require a hidden folder for the --list-threads flag", func() {335command := exec.Command(binaryPath, "--list-threads")336session, err := gexec.Start(command, io.Discard, io.Discard)337Expect(err).NotTo(HaveOccurred())338339Eventually(session).Should(gexec.Exit(exitFailure))340341output := string(session.Err.Contents())342Expect(output).To(ContainSubstring(".chatgpt-cli/history: no such file or directory"))343})344345it("should return an error when --new-thread is used with --set-thread", func() {346command := exec.Command(binaryPath, "--new-thread", "--set-thread", "some-thread")347session, err := gexec.Start(command, io.Discard, io.Discard)348Expect(err).NotTo(HaveOccurred())349350Eventually(session).Should(gexec.Exit(exitFailure))351352output := string(session.Err.Contents())353Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))354})355356it("should return an error when --new-thread is used with --thread", func() {357command := exec.Command(binaryPath, "--new-thread", "--thread", "some-thread")358session, err := gexec.Start(command, io.Discard, io.Discard)359Expect(err).NotTo(HaveOccurred())360361Eventually(session).Should(gexec.Exit(exitFailure))362363output := string(session.Err.Contents())364Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))365})366367it("should require an argument for the --set-model flag", func() {368command := exec.Command(binaryPath, "--set-model")369session, err := gexec.Start(command, io.Discard, io.Discard)370Expect(err).NotTo(HaveOccurred())371372Eventually(session).Should(gexec.Exit(exitFailure))373374output := string(session.Err.Contents())375Expect(output).To(ContainSubstring("flag needs an argument: --set-model"))376})377378it("should require an argument for the --set-thread flag", func() {379command := exec.Command(binaryPath, "--set-thread")380session, err := gexec.Start(command, io.Discard, io.Discard)381Expect(err).NotTo(HaveOccurred())382383Eventually(session).Should(gexec.Exit(exitFailure))384385output := string(session.Err.Contents())386Expect(output).To(ContainSubstring("flag needs an argument: --set-thread"))387})388389it("should require an argument for the --set-max-tokens flag", func() {390command := exec.Command(binaryPath, "--set-max-tokens")391session, err := gexec.Start(command, io.Discard, io.Discard)392Expect(err).NotTo(HaveOccurred())393394Eventually(session).Should(gexec.Exit(exitFailure))395396output := string(session.Err.Contents())397Expect(output).To(ContainSubstring("flag needs an argument: --set-max-tokens"))398})399400it("should require an argument for the --set-context-window flag", func() {401command := exec.Command(binaryPath, "--set-context-window")402session, err := gexec.Start(command, io.Discard, io.Discard)403Expect(err).NotTo(HaveOccurred())404405Eventually(session).Should(gexec.Exit(exitFailure))406407output := string(session.Err.Contents())408Expect(output).To(ContainSubstring("flag needs an argument: --set-context-window"))409})410411it("should warn when config.yaml does not exist and OPENAI_CONFIG_HOME is set", func() {412configHomeDir := "does-not-exist"413Expect(os.Setenv(internal.ConfigHomeEnv, configHomeDir)).To(Succeed())414415configFilePath := path.Join(configHomeDir, "config.yaml")416Expect(configFilePath).NotTo(BeAnExistingFile())417418command := exec.Command(binaryPath, "llm query")419session, err := gexec.Start(command, io.Discard, io.Discard)420Expect(err).NotTo(HaveOccurred())421422Eventually(session).Should(gexec.Exit(exitSuccess))423424output := string(session.Err.Contents())425Expect(output).To(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))426427// Unset the variable to prevent pollution428Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())429})430431it("should NOT warn when config.yaml does not exist and OPENAI_CONFIG_HOME is NOT set", func() {432configHomeDir := "does-not-exist"433Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())434435configFilePath := path.Join(configHomeDir, "config.yaml")436Expect(configFilePath).NotTo(BeAnExistingFile())437438command := exec.Command(binaryPath, "llm query")439session, err := gexec.Start(command, io.Discard, io.Discard)440Expect(err).NotTo(HaveOccurred())441442Eventually(session).Should(gexec.Exit(exitSuccess))443444output := string(session.Out.Contents())445Expect(output).NotTo(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))446})447448it("should require the chatgpt-cli folder but not an API key for the --set-model flag", func() {449Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())450451command := exec.Command(binaryPath, "--set-model", "123")452session, err := gexec.Start(command, io.Discard, io.Discard)453Expect(err).NotTo(HaveOccurred())454455Eventually(session).Should(gexec.Exit(exitFailure))456457output := string(session.Err.Contents())458Expect(output).To(ContainSubstring("config directory does not exist:"))459Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))460})461462it("should require the chatgpt-cli folder but not an API key for the --set-thread flag", func() {463Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())464465command := exec.Command(binaryPath, "--set-thread", "thread-name")466session, err := gexec.Start(command, io.Discard, io.Discard)467Expect(err).NotTo(HaveOccurred())468469Eventually(session).Should(gexec.Exit(exitFailure))470471output := string(session.Err.Contents())472Expect(output).To(ContainSubstring("config directory does not exist:"))473Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))474})475476it("should require the chatgpt-cli folder but not an API key for the --set-max-tokens flag", func() {477Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())478479command := exec.Command(binaryPath, "--set-max-tokens", "789")480session, err := gexec.Start(command, io.Discard, io.Discard)481Expect(err).NotTo(HaveOccurred())482483Eventually(session).Should(gexec.Exit(exitFailure))484485output := string(session.Err.Contents())486Expect(output).To(ContainSubstring("config directory does not exist:"))487Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))488})489490it("should require the chatgpt-cli folder but not an API key for the --set-context-window flag", func() {491Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())492493command := exec.Command(binaryPath, "--set-context-window", "789")494session, err := gexec.Start(command, io.Discard, io.Discard)495Expect(err).NotTo(HaveOccurred())496497Eventually(session).Should(gexec.Exit(exitFailure))498499output := string(session.Err.Contents())500Expect(output).To(ContainSubstring("config directory does not exist:"))501Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))502})503504it("should return the expected result for the --version flag", func() {505output := runCommand("--version")506507Expect(output).To(ContainSubstring(fmt.Sprintf("commit %s", gitCommit)))508Expect(output).To(ContainSubstring(fmt.Sprintf("version %s", gitVersion)))509})510511it("should return the expected result for the --list-models flag", func() {512output := runCommand("--list-models")513514Expect(output).To(ContainSubstring("* gpt-4o (current)"))515Expect(output).To(ContainSubstring("- gpt-3.5-turbo"))516Expect(output).To(ContainSubstring("- gpt-3.5-turbo-0301"))517})518519it("should return the expected result for the --query flag", func() {520Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "false")).To(Succeed())521522output := runCommand("--query", "some-query")523524expectedResponse := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`525Expect(output).To(ContainSubstring(expectedResponse))526Expect(output).NotTo(ContainSubstring("Token Usage:"))527})528529it("should display token usage after a query when configured to do so", func() {530Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "true")).To(Succeed())531532output := runCommand("--query", "tell me a 5 line joke")533Expect(output).To(ContainSubstring("Token Usage:"))534})535536it("prints debug information with the --debug flag", func() {537output := runCommand("--query", "tell me a joke", "--debug")538539Expect(output).To(ContainSubstring("Generated cURL command"))540Expect(output).To(ContainSubstring("/v1/chat/completions"))541Expect(output).To(ContainSubstring("--header \"Authorization: Bearer ${OPENAI_API_KEY}\""))542Expect(output).To(ContainSubstring("--header 'Content-Type: application/json'"))543Expect(output).To(ContainSubstring("\"model\":\"gpt-4o\""))544Expect(output).To(ContainSubstring("\"messages\":"))545Expect(output).To(ContainSubstring("Response"))546547Expect(os.Unsetenv("OPENAI_DEBUG")).To(Succeed())548})549550it("should assemble http errors as expected", func() {551Expect(os.Setenv(apiKeyEnvVar, "wrong-token")).To(Succeed())552553command := exec.Command(binaryPath, "--query", "some-query")554session, err := gexec.Start(command, io.Discard, io.Discard)555Expect(err).NotTo(HaveOccurred())556557Eventually(session).Should(gexec.Exit(exitFailure))558559output := string(session.Err.Contents())560561// see error.json562Expect(output).To(Equal("http status 401: Incorrect API key provided\n"))563})564565when("there is a hidden chatgpt-cli folder in the home dir", func() {566it.Before(func() {567filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")568Expect(os.MkdirAll(filePath, 0777)).To(Succeed())569})570571it.After(func() {572Expect(os.RemoveAll(filePath)).To(Succeed())573})574575it("should not require an API key for the --list-threads flag", func() {576historyPath := path.Join(filePath, "history")577Expect(os.MkdirAll(historyPath, 0777)).To(Succeed())578579Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())580581command := exec.Command(binaryPath, "--list-threads")582session, err := gexec.Start(command, io.Discard, io.Discard)583Expect(err).NotTo(HaveOccurred())584585Eventually(session).Should(gexec.Exit(exitSuccess))586})587588it("migrates the legacy history as expected", func() {589// Legacy history file should not exist590legacyFile := path.Join(filePath, "history")591Expect(legacyFile).NotTo(BeAnExistingFile())592593// History should not exist yet594historyFile := path.Join(filePath, "history", "default.json")595Expect(historyFile).NotTo(BeAnExistingFile())596597bytes, err := test.FileToBytes("history.json")598Expect(err).NotTo(HaveOccurred())599600Expect(os.WriteFile(legacyFile, bytes, 0644)).To(Succeed())601Expect(legacyFile).To(BeARegularFile())602603// Perform a query604command := exec.Command(binaryPath, "--query", "some-query")605session, err := gexec.Start(command, io.Discard, io.Discard)606Expect(err).NotTo(HaveOccurred())607608// The CLI response should be as expected609Eventually(session).Should(gexec.Exit(exitSuccess))610611output := string(session.Out.Contents())612613response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`614Expect(output).To(ContainSubstring(response))615616// The history file should have the expected content617Expect(path.Dir(historyFile)).To(BeADirectory())618content, err := os.ReadFile(historyFile)619620Expect(err).NotTo(HaveOccurred())621Expect(content).NotTo(BeEmpty())622Expect(string(content)).To(ContainSubstring(response))623624// The legacy file should now be a directory625Expect(legacyFile).To(BeADirectory())626Expect(legacyFile).NotTo(BeARegularFile())627628// The content was moved to the new file629Expect(string(content)).To(ContainSubstring("Of course! Which city are you referring to?"))630})631632it("should not require an API key for the --clear-history flag", func() {633Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())634635command := exec.Command(binaryPath, "--clear-history")636session, err := gexec.Start(command, io.Discard, io.Discard)637Expect(err).NotTo(HaveOccurred())638639Eventually(session).Should(gexec.Exit(exitSuccess))640})641642it("keeps track of history", func() {643// History should not exist yet644historyDir := path.Join(filePath, "history")645historyFile := path.Join(historyDir, "default.json")646Expect(historyFile).NotTo(BeAnExistingFile())647648// Perform a query and check response649response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`650output := runCommand("--query", "some-query")651Expect(output).To(ContainSubstring(response))652653// Check if history file was created with expected content654Expect(historyDir).To(BeADirectory())655checkHistoryContent := func(expectedContent string) {656content, err := os.ReadFile(historyFile)657Expect(err).NotTo(HaveOccurred())658Expect(string(content)).To(ContainSubstring(expectedContent))659}660checkHistoryContent(response)661662// Clear the history using the CLI663runCommand("--clear-history")664Expect(historyFile).NotTo(BeAnExistingFile())665666// Test omitting history through environment variable667omitHistoryEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "OMIT_HISTORY", 1)668envValue := "true"669Expect(os.Setenv(omitHistoryEnvKey, envValue)).To(Succeed())670671// Perform another query with history omitted672runCommand("--query", "some-query")673// The history file should NOT be recreated674Expect(historyFile).NotTo(BeAnExistingFile())675676// Cleanup: Unset the environment variable677Expect(os.Unsetenv(omitHistoryEnvKey)).To(Succeed())678})679680it("should not add binary data to the history", func() {681historyDir := path.Join(filePath, "history")682historyFile := path.Join(historyDir, "default.json")683Expect(historyFile).NotTo(BeAnExistingFile())684685response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`686687// Create a pipe to simulate binary input688r, w := io.Pipe()689defer r.Close()690691// Run the command with piped binary input692binaryData := []byte{0x00, 0xFF, 0x42, 0x10}693go func() {694defer w.Close()695_, err := w.Write(binaryData)696Expect(err).NotTo(HaveOccurred())697}()698699// Run the command with stdin redirected700output := runCommandWithStdin(r, "--query", "some-query")701Expect(output).To(ContainSubstring(response))702703Expect(historyDir).To(BeADirectory())704checkHistoryContent := func(expectedContent string) {705content, err := os.ReadFile(historyFile)706Expect(err).NotTo(HaveOccurred())707Expect(string(content)).To(ContainSubstring(expectedContent))708}709checkHistoryContent(response)710})711712it("should return the expected result for the --list-threads flag", func() {713historyDir := path.Join(filePath, "history")714Expect(os.Mkdir(historyDir, 0755)).To(Succeed())715716files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}717718Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())719720for _, file := range files {721file, err := os.Create(filepath.Join(historyDir, file))722Expect(err).NotTo(HaveOccurred())723724Expect(file.Close()).To(Succeed())725}726727output := runCommand("--list-threads")728729Expect(output).To(ContainSubstring("* default (current)"))730Expect(output).To(ContainSubstring("- thread1"))731Expect(output).To(ContainSubstring("- thread2"))732Expect(output).To(ContainSubstring("- thread3"))733})734735it("should delete the expected thread using the --delete-threads flag", func() {736historyDir := path.Join(filePath, "history")737Expect(os.Mkdir(historyDir, 0755)).To(Succeed())738739files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}740741Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())742743for _, file := range files {744file, err := os.Create(filepath.Join(historyDir, file))745Expect(err).NotTo(HaveOccurred())746747Expect(file.Close()).To(Succeed())748}749750runCommand("--delete-thread", "thread2")751752output := runCommand("--list-threads")753754Expect(output).To(ContainSubstring("* default (current)"))755Expect(output).To(ContainSubstring("- thread1"))756Expect(output).NotTo(ContainSubstring("- thread2"))757Expect(output).To(ContainSubstring("- thread3"))758})759760it("should delete the expected threads using the --delete-threads flag with a wildcard", func() {761historyDir := filepath.Join(filePath, "history")762Expect(os.Mkdir(historyDir, 0755)).To(Succeed())763764files := []string{765"start1.json", "start2.json", "start3.json",766"1end.json", "2end.json", "3end.json",767"1middle1.json", "2middle2.json", "3middle3.json",768"other1.json", "other2.json",769}770771createTestFiles := func(dir string, filenames []string) {772for _, filename := range filenames {773file, err := os.Create(filepath.Join(dir, filename))774Expect(err).NotTo(HaveOccurred())775Expect(file.Close()).To(Succeed())776}777}778779createTestFiles(historyDir, files)780781output := runCommand("--list-threads")782expectedThreads := []string{783"start1", "start2", "start3",784"1end", "2end", "3end",785"1middle1", "2middle2", "3middle3",786"other1", "other2",787}788for _, thread := range expectedThreads {789Expect(output).To(ContainSubstring("- " + thread))790}791792tests := []struct {793pattern string794remainingAfter []string795}{796{"start*", []string{"1end", "2end", "3end", "1middle1", "2middle2", "3middle3", "other1", "other2"}},797{"*end", []string{"1middle1", "2middle2", "3middle3", "other1", "other2"}},798{"*middle*", []string{"other1", "other2"}},799{"*", []string{}}, // Should delete all remaining threads800}801802for _, tt := range tests {803runCommand("--delete-thread", tt.pattern)804output = runCommand("--list-threads")805806for _, thread := range tt.remainingAfter {807Expect(output).To(ContainSubstring("- " + thread))808}809}810})811812it("should throw an error when a non-existent thread is deleted using the --delete-threads flag", func() {813command := exec.Command(binaryPath, "--delete-thread", "does-not-exist")814session, err := gexec.Start(command, io.Discard, io.Discard)815Expect(err).NotTo(HaveOccurred())816817Eventually(session).Should(gexec.Exit(exitFailure))818})819820it("should not throw an error --clear-history is called without there being a history", func() {821command := exec.Command(binaryPath, "--clear-history")822session, err := gexec.Start(command, io.Discard, io.Discard)823Expect(err).NotTo(HaveOccurred())824825Eventually(session).Should(gexec.Exit(exitSuccess))826})827828when("configurable flags are set", func() {829it.Before(func() {830configFile = path.Join(filePath, "config.yaml")831Expect(configFile).NotTo(BeAnExistingFile())832})833834it("has a configurable default model", func() {835oldModel := "gpt-4o"836newModel := "gpt-3.5-turbo-0301"837838// Verify initial model839output := runCommand("--list-models")840Expect(output).To(ContainSubstring("* " + oldModel + " (current)"))841Expect(output).To(ContainSubstring("- " + newModel))842843// Update model844runCommand("--set-model", newModel)845846// Check configFile is created and contains the new model847Expect(configFile).To(BeAnExistingFile())848checkConfigFileContent(newModel)849850// Verify updated model through --list-models851output = runCommand("--list-models")852853Expect(output).To(ContainSubstring("* " + newModel + " (current)"))854})855856it("has a configurable default context-window", func() {857defaults := config.NewStore().ReadDefaults()858859// Initial check for default context-window860output := runCommand("--config")861Expect(output).To(ContainSubstring(strconv.Itoa(defaults.ContextWindow)))862863// Update and verify context-window864newContextWindow := "100000"865runCommand("--set-context-window", newContextWindow)866Expect(configFile).To(BeAnExistingFile())867checkConfigFileContent(newContextWindow)868869// Verify update through --config870output = runCommand("--config")871Expect(output).To(ContainSubstring(newContextWindow))872873// Environment variable takes precedence874envContext := "123"875modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "CONTEXT_WINDOW", 1)876Expect(os.Setenv(modelEnvKey, envContext)).To(Succeed())877878// Verify environment variable override879output = runCommand("--config")880Expect(output).To(ContainSubstring(envContext))881Expect(os.Unsetenv(modelEnvKey)).To(Succeed())882})883884it("has a configurable default max-tokens", func() {885defaults := config.NewStore().ReadDefaults()886887// Initial check for default max-tokens888output := runCommand("--config")889Expect(output).To(ContainSubstring(strconv.Itoa(defaults.MaxTokens)))890891// Update and verify max-tokens892newMaxTokens := "81724"893runCommand("--set-max-tokens", newMaxTokens)894Expect(configFile).To(BeAnExistingFile())895checkConfigFileContent(newMaxTokens)896897// Verify update through --config898output = runCommand("--config")899Expect(output).To(ContainSubstring(newMaxTokens))900901// Environment variable takes precedence902modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "MAX_TOKENS", 1)903Expect(os.Setenv(modelEnvKey, newMaxTokens)).To(Succeed())904905// Verify environment variable override906output = runCommand("--config")907Expect(output).To(ContainSubstring(newMaxTokens))908Expect(os.Unsetenv(modelEnvKey)).To(Succeed())909})910911it("has a configurable default thread", func() {912defaults := config.NewStore().ReadDefaults()913914// Initial check for default thread915output := runCommand("--config")916Expect(output).To(ContainSubstring(defaults.Thread))917918// Update and verify thread919newThread := "new-thread"920runCommand("--set-thread", newThread)921Expect(configFile).To(BeAnExistingFile())922checkConfigFileContent(newThread)923924// Verify update through --config925output = runCommand("--config")926Expect(output).To(ContainSubstring(newThread))927928// Environment variable takes precedence929threadEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "THREAD", 1)930Expect(os.Setenv(threadEnvKey, newThread)).To(Succeed())931932// Verify environment variable override933output = runCommand("--config")934Expect(output).To(ContainSubstring(newThread))935Expect(os.Unsetenv(threadEnvKey)).To(Succeed())936})937})938})939940when("configuration precedence", func() {941var (942defaultModel = "gpt-4o"943newModel = "gpt-3.5-turbo-0301"944envModel = "gpt-3.5-env-model"945envVar string946)947948it.Before(func() {949envVar = strings.Replace(apiKeyEnvVar, "API_KEY", "MODEL", 1)950filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")951Expect(os.MkdirAll(filePath, 0777)).To(Succeed())952953configFile = path.Join(filePath, "config.yaml")954Expect(configFile).NotTo(BeAnExistingFile())955})956957it("uses environment variable over config file", func() {958// Step 1: Set a model in the config file.959runCommand("--set-model", newModel)960checkConfigFileContent(newModel)961962// Step 2: Verify the model from config is used.963output := runCommand("--list-models")964Expect(output).To(ContainSubstring("* " + newModel + " (current)"))965966// Step 3: Set environment variable and verify it takes precedence.967Expect(os.Setenv(envVar, envModel)).To(Succeed())968output = runCommand("--list-models")969Expect(output).To(ContainSubstring("* " + envModel + " (current)"))970971// Step 4: Unset environment variable and verify it falls back to config file.972Expect(os.Unsetenv(envVar)).To(Succeed())973output = runCommand("--list-models")974Expect(output).To(ContainSubstring("* " + newModel + " (current)"))975})976977it("uses command-line flag over environment variable", func() {978// Step 1: Set environment variable.979Expect(os.Setenv(envVar, envModel)).To(Succeed())980981// Step 2: Verify environment variable does not override flag.982output := runCommand("--model", newModel, "--list-models")983Expect(output).To(ContainSubstring("* " + newModel + " (current)"))984})985986it("falls back to default when config and env are absent", func() {987// Step 1: Ensure no config file and no environment variable.988Expect(os.Unsetenv(envVar)).To(Succeed())989990// Step 2: Verify it falls back to the default model.991output := runCommand("--list-models")992Expect(output).To(ContainSubstring("* " + defaultModel + " (current)"))993})994})995996when("show-history flag is used", func() {997var tmpDir string998var err error999var historyFile string10001001it.Before(func() {1002RegisterTestingT(t)1003tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")1004Expect(err).NotTo(HaveOccurred())1005historyFile = filepath.Join(tmpDir, "default.json")10061007messages := []api.Message{1008{Role: "user", Content: "Hello"},1009{Role: "assistant", Content: "Hi, how can I help you?"},1010{Role: "user", Content: "Tell me about the weather"},1011{Role: "assistant", Content: "It's sunny today."},1012}1013data, err := json.Marshal(messages)1014Expect(err).NotTo(HaveOccurred())10151016Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())10171018// This is legacy: we need a config dir in order to have a history dir1019filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")1020Expect(os.MkdirAll(filePath, 0777)).To(Succeed())10211022Expect(os.Setenv("OPENAI_DATA_HOME", tmpDir)).To(Succeed())1023})10241025it("prints the history for the default thread", func() {1026output := runCommand("--show-history")10271028// Check that the output contains the history as expected1029Expect(output).To(ContainSubstring("**USER** 👤:\nHello"))1030Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nHi, how can I help you?"))1031Expect(output).To(ContainSubstring("**USER** 👤:\nTell me about the weather"))1032Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nIt's sunny today."))1033})10341035it("prints the history for a specific thread when specified", func() {1036specificThread := "specific-thread"1037specificHistoryFile := filepath.Join(tmpDir, specificThread+".json")10381039// Create a specific thread with custom history1040messages := []api.Message{1041{Role: "user", Content: "What's the capital of Belgium?"},1042{Role: "assistant", Content: "The capital of Belgium is Brussels."},1043}1044data, err := json.Marshal(messages)1045Expect(err).NotTo(HaveOccurred())1046Expect(os.WriteFile(specificHistoryFile, data, 0644)).To(Succeed())10471048// Run the --show-history flag with the specific thread1049output := runCommand("--show-history", specificThread)10501051// Check that the output contains the history as expected1052Expect(output).To(ContainSubstring("**USER** 👤:\nWhat's the capital of Belgium?"))1053Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThe capital of Belgium is Brussels."))1054})10551056it("concatenates user messages correctly", func() {1057// Create history where two user messages are concatenated1058messages := []api.Message{1059{Role: "user", Content: "Part one"},1060{Role: "user", Content: " and part two"},1061{Role: "assistant", Content: "This is a response."},1062}1063data, err := json.Marshal(messages)1064Expect(err).NotTo(HaveOccurred())1065Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())10661067output := runCommand("--show-history")10681069// Check that the concatenated user messages are displayed correctly1070Expect(output).To(ContainSubstring("**USER** 👤:\nPart one and part two"))1071Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThis is a response."))1072})1073})1074})1075}107610771078