123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 | package tui
import (
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"syscall"
"testing"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"blinker/internal/config"
)
// Every setting in agent.json and config.json is in /config, once.
func TestSettingsCoverConfig(t *testing.T) {
listed := map[string]bool{}
for _, s := range settings {
if s.file != "" {
continue
}
if listed[s.key] {
t.Errorf("%s is listed twice", s.key)
}
listed[s.key] = true
}
for _, typ := range []reflect.Type{reflect.TypeFor[config.Config](), reflect.TypeFor[config.Sandbox]()} {
for i := range typ.NumField() {
key, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",")
if key == "-" {
continue
}
if !listed[key] {
t.Errorf("%s is not in /config", key)
}
delete(listed, key)
}
}
for key := range listed {
t.Errorf("%s is not a setting", key)
}
}
func byKey(t *testing.T, key string) setting {
t.Helper()
for _, s := range settings {
if s.key == key {
return s
}
}
t.Fatalf("no setting %s", key)
return setting{}
}
func TestShowSetting(t *testing.T) {
cfg := config.Defaults()
cfg.APIKey = "sk-secret"
cfg.RequestTimeout = config.Duration(90 * time.Minute)
for key, want := range map[string]string{
"base_url": "http://localhost:8080/v1", // no quotes
"api_key": "*********", // as long as sk-secret
"model": "none",
"context_window": "ask the server",
"thinking": "on",
"sampling": "sent",
"allow_private_egress": "blocked",
"temperature": "0.6",
"command_timeout": "2m",
"request_timeout": "1h30m",
"proxy_ports": "443, 80",
"extra_blocklist": "none",
"thinking_param": "chat_template_kwargs",
} {
if got := show(cfg, byKey(t, key)); got != want {
t.Errorf("%s shows %q, want %q", key, got, want)
}
}
cfg.DiffCommand = []string{"delta", "--width={width}"}
if got := show(cfg, byKey(t, "diff_command")); got != "delta --width={width}" {
t.Errorf("diff_command shows %q", got)
}
cfg.CommandTimeout = config.Duration(30 * time.Second)
if got := show(cfg, byKey(t, "command_timeout")); got != "30s" {
t.Errorf("30s shows %q", got)
}
}
func TestSetSetting(t *testing.T) {
cfg := config.Defaults()
for _, tc := range []struct {
key, typed string
check func(config.Config) bool
}{
{"command_timeout", "5m", func(c config.Config) bool { return time.Duration(c.CommandTimeout) == 5*time.Minute }},
{"model", " 4 ", func(c config.Config) bool { return c.Model == "4" }},
{"proxy_ports", "443, 8080,", func(c config.Config) bool { return slices.Equal(c.Sandbox.ProxyPorts, []int{443, 8080}) }},
{"proxy_ports", "", func(c config.Config) bool { return len(c.Sandbox.ProxyPorts) == 0 }},
{"diff_command", " delta --width={width} ", func(c config.Config) bool {
return slices.Equal(c.DiffCommand, []string{"delta", "--width={width}"})
}},
{"max_tokens", "", func(c config.Config) bool { return c.MaxTokens == 0 }},
{"temperature", "1.2", func(c config.Config) bool { return c.Temperature == 1.2 }},
} {
got, err := set(cfg, byKey(t, tc.key), tc.typed)
if err != nil || !tc.check(got) {
t.Errorf("%s = %q: %+v, %v", tc.key, tc.typed, got, err)
}
}
if !slices.Equal(cfg.Sandbox.ProxyPorts, []int{443, 80}) {
t.Error("set changed the config it was given")
}
for key, typed := range map[string]string{
"max_tokens": "lots", // not a number
"proxy_ports": "443, web", // not numbers
"temperature": "3", // out of range
"command_timeout": "5", // no unit
"scratch_root": "relative", // not absolute
"base_url": "nowhere", // not a URL
} {
if got, err := set(cfg, byKey(t, key), typed); err == nil {
t.Errorf("%s = %q accepted: %+v", key, typed, got)
}
}
}
func TestSettingsScreen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, config.AgentFile)
cfg := config.Defaults()
cfg.APIKey = "sk-secret"
s := newSettings(cfg, dir, 80)
if strings.Contains(s.View(), "sk-secret") || strings.Contains(s.View(), `"`) {
t.Errorf("the screen shows the key or quotes:\n%s", s.View())
}
choose := func(key string) {
for s.sel = 0; settings[s.sel].key != key; s.sel++ {
}
}
enter := func() { s, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) }
choose("max_tokens")
enter()
if !s.editing || s.input.Value() != "32768" {
t.Fatalf("editing %v, value %q", s.editing, s.input.Value())
}
// Not valid: kept for fixing, and nothing saved.
s.input.SetValue("-5")
enter()
if !s.editing || s.errText == "" {
t.Errorf("an invalid value was accepted")
}
if _, err := os.Stat(path); err == nil {
t.Error("an invalid value was saved")
}
s.input.SetValue("1000")
enter()
if s.editing || s.errText != "" || s.cfg.MaxTokens != 1000 {
t.Fatalf("editing %v, error %q, max_tokens %d", s.editing, s.errText, s.cfg.MaxTokens)
}
// On/off toggles, and choices cycle, without typing.
choose("thinking")
enter()
choose("max_tokens_param")
enter()
if s.editing || s.cfg.Thinking || s.cfg.MaxTokensParam != "max_completion_tokens" {
t.Errorf("thinking %v, max_tokens_param %q", s.cfg.Thinking, s.cfg.MaxTokensParam)
}
// The API key is typed blind, from empty.
choose("api_key")
enter()
if s.input.Value() != "" || s.input.EchoMode != textinput.EchoPassword {
t.Errorf("editing the key starts with %q", s.input.Value())
}
s, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
saved, err := config.Load(dir)
if err != nil || saved.MaxTokens != 1000 || saved.Thinking || saved.APIKey != "sk-secret" {
t.Errorf("saved %+v, %v", saved, err)
}
_, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if done, ok := cmd().(settingsDone); !ok || !done.saved || done.cfg.MaxTokens != 1000 {
t.Errorf("closing gave %+v", done)
}
// Ctrl+c closes it too, even mid-edit, without saving the edit.
choose("max_tokens")
enter()
s.input.SetValue("2000")
_, cmd = s.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if done, ok := cmd().(settingsDone); !ok || done.cfg.MaxTokens != 1000 {
t.Errorf("ctrl+c gave %+v", done)
}
}
// A file setting shows how long the file is, and enter edits it.
func TestSettingsFile(t *testing.T) {
dir := filepath.Join(t.TempDir(), "blinker")
s := newSettings(config.Defaults(), dir, 80)
for s.sel = 0; settings[s.sel].file != "AGENTS.md"; s.sel++ {
}
if !strings.Contains(s.View(), "Your instructions none") {
t.Errorf("no file shows as none:\n%s", s.View())
}
_, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if cmd == nil {
t.Error("enter does not open the editor")
}
path := filepath.Join(dir, "AGENTS.md")
if err := os.WriteFile(path, []byte("Be brief.\nUse tabs.\n"), 0o600); err != nil {
t.Fatal(err)
}
s, _ = s.Update(settingsFileEdited{})
if !strings.Contains(s.View(), "Your instructions 2 lines") {
t.Errorf("the file's length is not shown:\n%s", s.View())
}
// A FIFO there would block a plain read until something wrote to it.
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(path, 0o600); err != nil {
t.Fatal(err)
}
done := make(chan settingsModel, 1)
go func() { s, _ := s.Update(settingsFileEdited{}); done <- s }()
select {
case s = <-done:
case <-time.After(5 * time.Second):
t.Fatal("a FIFO hangs the settings screen")
}
if !strings.Contains(s.View(), "Your instructions unreadable") {
t.Errorf("a FIFO does not show as unreadable:\n%s", s.View())
}
}
|