123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 | package tui
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"blinker/internal/agent"
"blinker/internal/config"
"blinker/internal/policy"
"blinker/internal/tools"
)
// Bubble Tea passes the model by value, so streaming several tokens must
// not copy state that cannot be copied (it used to panic on the second
// token).
func TestStreamingTokens(t *testing.T) {
var m tea.Model = Model{input: textarea.New(), reply: &strings.Builder{}, reasoning: &strings.Builder{}, width: 80, running: true}
for _, tok := range []agent.Token{{Text: "thinking "}, {Text: "hard", Reasoning: true}, {Text: "Hel"}, {Text: "lo"}} {
tok.Reasoning = tok.Text == "hard"
m, _ = m.Update(eventMsg{tok})
}
if got := m.(Model).reply.String(); got != "thinking Hello" {
t.Errorf("reply = %q", got)
}
m, _ = m.Update(eventMsg{agent.ReplyDone{}})
if mm := m.(Model); mm.reply.Len() != 0 || !mm.printing {
t.Errorf("after the reply: %q, printing %v", mm.reply.String(), mm.printing)
}
}
func TestDescribeChange(t *testing.T) {
env := policy.Env{Home: "/home/u", Project: "/home/u/proj"}
docs := policy.FileRule{Path: "docs", Access: policy.Read}
env1 := policy.FileRule{Path: "**/.env", Access: policy.None}
old := &policy.File{Files: []policy.FileRule{docs, env1}}
new := &policy.File{
Includes: []policy.Include{{Name: "agent-unsafe"}},
Files: []policy.FileRule{env1, docs, {Path: "~/other", Access: policy.Write}},
Network: []policy.NetRule{{Host: "*.example.com", Access: policy.Ask}},
Tools: []policy.ToolRule{{Tool: "Fetch", Access: policy.Allow}},
}
got := strings.Join(describeChange(old, new, env, nil, policy.UnsafeMode), "\n")
for _, want := range []string{
"Adds: the rules of policy agent-unsafe",
"Adds: ~/other and everything in it: once you approve can read and write",
"Adds: connections to the subdomains of example.com (not example.com itself): Unsafe_Bash asks you first",
"Adds: the Fetch tool runs once you approve each call",
"Moves rules.",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "Removes") || strings.Contains(got, "docs and") {
t.Errorf("unchanged rules listed:\n%s", got)
}
// The safe policy never asks.
got = strings.Join(describeChange(old, new, env, nil, policy.SafeMode), "\n")
for _, want := range []string{"without asking can read and write", "Safe_Bash blocked (Safe_Bash never asks)", "the Fetch tool runs without asking"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
func TestExternalDiff(t *testing.T) {
d := "--- x\n+++ x\n@@ -1 +1 @@\n-old\n+new\n"
// The script prints the diff back with a color, a title change and
// the width; exit 1 (differences found) is not a failure.
out, err := externalDiff([]string{"sh", "-c", `printf '\033]0;t\007\033[31m'; cat; echo {width}; exit 1`}, d, 0, 30)
if err != nil {
t.Fatal(err)
}
if want := "\x1b[31m" + d + "30\x1b[0m"; out != want {
t.Errorf("got %q, want %q", out, want)
}
out, err = externalDiff([]string{"cat"}, d, 2, 30)
if err != nil || !strings.HasPrefix(out, "--- x\n+++ x\x1b[0m\n") || !strings.Contains(out, "3 more lines") {
t.Errorf("limit 2: got %q, %v", out, err)
}
if _, err := externalDiff([]string{"sh", "-c", "exit 2"}, d, 0, 30); err == nil {
t.Error("exit 2 should fail")
}
if _, err := externalDiff([]string{"true"}, d, 0, 30); err == nil {
t.Error("printing nothing should fail")
}
}
// A pager question shows its body and diff in a pane that fits the screen
// and scrolls, and leaves them out of the scrollback.
func TestPagerQuestion(t *testing.T) {
var m tea.Model = Model{input: textarea.New(), reply: &strings.Builder{}, reasoning: &strings.Builder{}, width: 80, height: 20}
var d strings.Builder
d.WriteString("--- a/f\n+++ b/f\n@@ -1,50 +1,50 @@\n")
for i := range 50 {
fmt.Fprintf(&d, "+line %d\n", i)
}
reply := make(chan tools.Answer, 1)
q := tools.Question{Title: "Commit?", Body: "The message", Diff: d.String(), Pager: true,
Opts: []tools.Option{{Label: "Commit", Allow: true}, {Label: "Deny"}}}
m, _ = m.Update(questionMsg{ctx: context.Background(), q: q, reply: reply})
if out := strings.Join(m.(Model).out, "\n"); strings.Contains(out, "line 0") || strings.Contains(out, "The message") {
t.Errorf("the pager's contents went to the scrollback: %q", out)
}
view := func() string { return ansi.Strip(m.View().Content) }
if v := view(); strings.Count(v, "\n")+1 > 20 || !strings.Contains(v, "The message") || strings.Contains(v, "line 49") {
t.Errorf("first page (%d lines):\n%s", strings.Count(v, "\n")+1, v)
}
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnd})
if v := view(); !strings.Contains(v, "line 49") || strings.Contains(v, "The message") || strings.Count(v, "\n")+1 > 20 {
t.Errorf("last page:\n%s", v)
}
m.(Model).question.shown = time.Now().Add(-time.Second) // past questionGrace
m, _ = m.Update(tea.KeyPressMsg{Code: '2', Text: "2"})
if a := <-reply; a.Option.Label != "Deny" {
t.Errorf("answer = %+v", a)
}
}
// Keys typed as a question appears, meant for something else, do not
// answer it.
func TestQuestionGrace(t *testing.T) {
var m tea.Model = Model{input: textarea.New(), reply: &strings.Builder{}, reasoning: &strings.Builder{}, width: 80, height: 20}
reply := make(chan tools.Answer, 1)
q := tools.Question{Title: "Run?", Body: "rm -rf build", Opts: []tools.Option{{Label: "Run", Allow: true}, {Label: "Deny"}}}
m, _ = m.Update(questionMsg{ctx: context.Background(), q: q, reply: reply})
for _, k := range []tea.KeyPressMsg{{Code: tea.KeyEnter}, {Code: '1', Text: "1"}} {
m, _ = m.Update(k)
}
select {
case a := <-reply:
t.Fatalf("answered at once: %+v", a)
default:
}
m.(Model).question.shown = time.Now().Add(-time.Second)
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if a := <-reply; a.Option.Label != "Run" {
t.Errorf("answer = %+v", a)
}
// Denying needs no wait.
m, _ = m.Update(questionMsg{ctx: context.Background(), q: q, reply: reply})
m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if a := <-reply; a.Option.Allow {
t.Errorf("escape: %+v", a)
}
}
// Setup asks for the context size only when the server does not say it.
func TestSetupContextSize(t *testing.T) {
dir := t.TempDir()
cfg := config.Defaults()
cfg.ContextWindow = 4096
s := newSetup(cfg, dir, nil)
s.step = stepTesting
if s, _ := s.Update(testedMsg{window: 8192}); s.cfg.ContextWindow != 0 {
t.Errorf("with the server's size, saved %d; want 0, to ask the server", s.cfg.ContextWindow)
}
s, _ = s.Update(testedMsg{})
if s.step != stepContext || s.window.Value() != "4096" {
t.Fatalf("step %v, value %q", s.step, s.window.Value())
}
s.window.SetValue("lots")
if s, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); s.errText == "" {
t.Error("took a size that is not a number")
}
s.window.SetValue("32768")
if s, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); s.cfg.ContextWindow != 32768 || s.errText != "" {
t.Errorf("saved %d (%s)", s.cfg.ContextWindow, s.errText)
}
if saved, err := config.Load(dir); err != nil || saved.ContextWindow != 32768 {
t.Errorf("the file has %d, %v", saved.ContextWindow, err)
}
}
// vim, opening the model's files, runs no program on them by their name
// (its tar plugin lists a .tar with tar).
func TestEditorFlagsStopPlugins(t *testing.T) {
vim, err := exec.LookPath("vim")
if err != nil {
t.Skip("vim not installed")
}
dir := t.TempDir()
log := filepath.Join(dir, "log")
for _, prog := range []string{"tar", "unzip", "gzip", "gunzip"} {
if err := os.WriteFile(filepath.Join(dir, prog), []byte("#!/bin/sh\necho \"$0 $*\" >> "+log+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
var files []string
for _, name := range []string{"x.tar", "x.zip", "x.gz"} {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte("not really\n"), 0o600); err != nil {
t.Fatal(err)
}
files = append(files, p)
}
// A vimrc of its own, which (unlike -u NONE) still loads the plugins;
// they do nothing in 'compatible' mode, which users leave.
vimrc := filepath.Join(dir, "vimrc")
if err := os.WriteFile(vimrc, []byte("set nocompatible\n"), 0o600); err != nil {
t.Fatal(err)
}
for _, f := range files {
cmd := exec.Command(vim, append(append([]string{"-X", "--not-a-term", "-i", "NONE", "-u", vimrc}, editorFlags...), "-R", "-c", "qa!", f)...)
cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"))
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("vim: %v\n%s", err, out)
}
}
if data, err := os.ReadFile(log); err == nil {
t.Errorf("vim ran programs on the files:\n%s", data)
}
}
// The diff renderer reads the model's changes: it gets no API key.
func TestExternalDiffEnvironment(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-secret")
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
out, err := externalDiff([]string{"sh", "-c", `cat >/dev/null; env`}, "--- x\n+++ x\n@@ -1 +1 @@\n-a\n+b\n", 0, 80)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "secret") || !strings.Contains(out, "COLUMNS=80") {
t.Errorf("environment:\n%s", out)
}
}
// Reopening /config starts from what the last /config saved, not from the
// settings blinker started with, which saving again would put back.
func TestSettingsReopen(t *testing.T) {
var m tea.Model = Model{input: textarea.New(), reply: &strings.Builder{}, reasoning: &strings.Builder{}, width: 80}
cfg := config.Defaults()
cfg.MaxTokens = 1000
m, _ = m.Update(settingsDone{saved: true, cfg: cfg})
mm := m.(Model)
mm.command("/config")
if mm.settings == nil || mm.settings.cfg.MaxTokens != 1000 {
t.Errorf("/config reopened with %+v", mm.settings)
}
}
func TestShortPath(t *testing.T) {
for p, want := range map[string]string{
"/home/u": "~",
"/home/u/..cache": "~/..cache",
"/home/uv/x": "/home/uv/x",
"/etc": "/etc",
} {
if got := shortPath(p, "/home/u"); got != want {
t.Errorf("shortPath(%q) = %q, want %q", p, got, want)
}
}
}
|