package tui import ( "context" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "harness/internal/approval" "harness/internal/llm" ) func TestPrettyModel(t *testing.T) { cases := map[string]string{ "claude-opus-5": "Opus 5", "claude-sonnet-5": "Sonnet 5", "claude-haiku-4-5-20251001": "Haiku 4.5", "claude-opus-5[1m]": "Opus 5[1m]", "claude-sonnet-4-5-latest": "Sonnet 4.5", "gpt-4o": "gpt-4o", // not ours: left alone rather than mangled "some/gateway/model": "some/gateway/model", "": "", } for in, want := range cases { if got := prettyModel(in); got != want { t.Errorf("prettyModel(%q) = %q, want %q", in, got, want) } } } // The welcome block is laid out in columns, so a terminal too narrow to hold // both must drop the mascot rather than slice it into noise. func TestSplashDropsMascotWhenNarrow(t *testing.T) { o := Options{Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Version: "v0.6.0"} wide := splash(o, 100) if !strings.Contains(wide, mascot[2]) { t.Error("mascot missing at a comfortable width") } if !strings.Contains(wide, "/tmp/repo") { t.Error("root missing from the welcome block") } for _, line := range strings.Split(strings.TrimRight(wide, "\n"), "\n") { if w := lipgloss.Width(line); w > 100 { t.Errorf("welcome line is %d cells wide, over the 100 available: %q", w, line) } } // A deeply nested working directory is a long line, not a narrow // terminal: it gets truncated, and the mascot stays. deep := Options{Client: o.Client, Version: o.Version, Root: "/tmp/claude-1000/-home-joni-Work-GO-coding-harness/b520442d-29dd-4870/scratchpad/demo"} if !strings.Contains(splash(deep, 100), mascot[2]) { t.Error("a long root path dropped the mascot on a wide terminal") } narrow := splash(o, 24) if strings.Contains(narrow, mascot[2]) { t.Error("mascot survived into a terminal too narrow for it") } if !strings.Contains(narrow, "harness") { t.Error("narrow welcome block lost its name") } } func TestStatusShowsContextGauge(t *testing.T) { m := newTestModel(t) m.opts.ContextWindow = 200_000 m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) m.Update(evUsage{usage: llm.Usage{InputTokens: 2_000, CacheReadInputTokens: 18_000, OutputTokens: 500}}) status := m.status() if !strings.Contains(status, "20.0k") { t.Errorf("context size missing from the status bar: %q", status) } if !strings.Contains(status, "(10%)") { t.Errorf("context percentage wrong or missing: %q", status) } // A second turn replaces the gauge rather than adding to it: the context // is how much the model read this time, not the sum of every turn. m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000, CacheReadInputTokens: 3_000}}) if got := m.status(); !strings.Contains(got, "(2%)") { t.Errorf("gauge accumulated across turns instead of replacing: %q", got) } } func TestShiftTabCyclesApprovalMode(t *testing.T) { m := newTestModel(t) if strings.Contains(m.modeLine(), "accept-all") { t.Fatal("started in accept-all without being asked to") } m.Update(key("shift+tab")) if !m.opts.Policy.YOLO { t.Error("shift+tab did not turn accept-all on") } if !strings.Contains(m.modeLine(), "accept-all on") { t.Errorf("mode line does not report accept-all: %q", m.modeLine()) } if !strings.Contains(m.tr.render(), "accept-all on") { t.Error("a change to what the agent may do was not recorded in the transcript") } m.Update(key("shift+tab")) if m.opts.Policy.YOLO { t.Error("shift+tab did not turn accept-all back off") } } func TestInputBoxGrowsWithContentAndStops(t *testing.T) { m := newTestModel(t) if got := m.input.Height(); got != 1 { t.Errorf("empty prompt is %d lines tall, want 1", got) } m.input.SetValue("one\ntwo\nthree") m.growInput() if got := m.input.Height(); got != 3 { t.Errorf("three lines of input gave a box %d tall", got) } m.input.SetValue(strings.Repeat("x\n", 40)) m.growInput() if got := m.input.Height(); got != inputMaxLines { t.Errorf("box grew to %d, want it capped at %d", got, inputMaxLines) } } // The numbered options are the primary way to answer, so the number a person // reads must reach the decision it is printed next to. func TestApprovalNumbersMatchTheirLabels(t *testing.T) { cases := []struct { name string rememberable bool want map[string]approval.Decision }{ {"rememberable", true, map[string]approval.Decision{ "1": approval.Once, "2": approval.AlwaysThisCommand, "3": approval.AlwaysThisTool, "4": approval.Deny, }}, {"opaque", false, map[string]approval.Decision{ "1": approval.Once, "2": approval.AlwaysThisTool, "3": approval.Deny, }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { for k, want := range tc.want { m := newTestModel(t) req := approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: tc.rememberable} reply := pend(m, req) view := m.approvalView() opts := m.choices(req) idx := int(k[0] - '1') if !strings.Contains(view, opts[idx].label) { t.Fatalf("option %s (%q) is not in the box:\n%s", k, opts[idx].label, view) } m.Update(key(k)) select { case got := <-reply: if got != want { t.Errorf("key %s sent %v, want %v", k, got, want) } default: t.Fatalf("key %s answered nothing", k) } } }) } } // The chrome grows and shrinks — the prompt box with what is typed, the // approval box when it appears — so the view has to stay exactly as tall as // the terminal rather than pushing its own top off the screen. func TestViewFillsExactlyTheTerminalHeight(t *testing.T) { for _, size := range [][2]int{{80, 24}, {120, 40}, {60, 30}} { m := newTestModel(t) m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) for i := 0; i < 40; i++ { m.tr.addUser("a line of scrollback") } m.sync() if got := lipgloss.Height(m.View()); got != size[1] { t.Errorf("%dx%d: view is %d rows, want %d", size[0], size[1], got, size[1]) } m.input.SetValue("one\ntwo\nthree\nfour") m.growInput() if got := lipgloss.Height(m.View()); got != size[1] { t.Errorf("%dx%d with a grown prompt: view is %d rows, want %d", size[0], size[1], got, size[1]) } pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "rm -rf /", Rememberable: true}) if got := lipgloss.Height(m.View()); got != size[1] { t.Errorf("%dx%d while approving: view is %d rows, want %d", size[0], size[1], got, size[1]) } } } // Every line of chrome has to fit the terminal it is drawn in; a status bar // one cell too wide wraps and silently eats a row of the transcript. func TestChromeNeverExceedsTerminalWidth(t *testing.T) { for _, w := range []int{40, 60, 80, 120} { m := newTestModel(t) m.opts.Effort = "high" m.Update(tea.WindowSizeMsg{Width: w, Height: 24}) m.Update(evUsage{usage: llm.Usage{InputTokens: 24_000, CacheReadInputTokens: 180_000, OutputTokens: 4_200}}) m.phase = phaseRunning for name, line := range map[string]string{ "activity": m.activity(), "status": m.status(), "mode": m.modeLine(), "input": m.inputBox(), } { for _, row := range strings.Split(line, "\n") { if got := lipgloss.Width(row); got > w { t.Errorf("width %d: %s line is %d cells: %q", w, name, got, row) } } } } } // A transcript entry the renderer does not know about must not vanish; the // welcome block is passed through verbatim. func TestRawEntryIsPassedThrough(t *testing.T) { tr := newTranscript(80) tr.addRaw("▐██████▌ verbatim\n") if got := tr.render(); !strings.Contains(got, "▐██████▌ verbatim") { t.Errorf("raw entry was rewritten: %q", got) } } func TestCamelNamesTools(t *testing.T) { for in, want := range map[string]string{ "read_file": "ReadFile", "bash": "Bash", "edit_file": "EditFile", "": "", } { if got := camel(in); got != want { t.Errorf("camel(%q) = %q, want %q", in, got, want) } } } func TestNewModelOpensWithTheWelcomeBlock(t *testing.T) { m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), Banner: []string{"resumed session abc"}, }) out := m.tr.render() if !strings.Contains(out, "harness") { t.Error("welcome block missing") } if i, j := strings.Index(out, "harness"), strings.Index(out, "resumed session abc"); i < 0 || j < 0 || i > j { t.Error("the banner should follow the welcome block, not precede it") } } func TestDiffStatCountsEditsNotHeaders(t *testing.T) { diff := "--- a/x.go\n+++ b/x.go\n@@ -1,2 +1,4 @@\n ctx\n-gone\n+one\n+two\n+three\n" if got, want := diffStat(strings.Split(diff, "\n")), "3 additions, 1 removal"; got != want { t.Errorf("diffStat = %q, want %q", got, want) } // The +++/--- file headers look like edits and are not. if got := diffStat([]string{"--- a/x.go", "+++ b/x.go"}); got != "0 additions, 0 removals" { t.Errorf("file headers counted as edits: %q", got) } tr := newTranscript(80) tr.startTool("edit_file", "path=x.go") tr.finishTool("edit_file", diff, false) if out := tr.render(); !strings.Contains(out, "3 additions, 1 removal") { t.Errorf("diff rendered without its stat line:\n%s", out) } } // Cache reads are the number this harness exists to move, so they belong on // the status bar rather than only in the metrics file. func TestStatusReportsCacheReadsSeparately(t *testing.T) { m := newTestModel(t) m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) m.Update(evUsage{usage: llm.Usage{InputTokens: 4_200, CacheReadInputTokens: 58_000, OutputTokens: 1_340}}) status := m.status() for _, want := range []string{"4.2k", "58.0k", "1.3k"} { if !strings.Contains(status, want) { t.Errorf("%s missing from the status bar: %q", want, status) } } // With nothing cached there is no cache field to read past. m2 := newTestModel(t) m2.Update(evUsage{usage: llm.Usage{InputTokens: 900, OutputTokens: 100}}) if strings.Contains(m2.status(), "⟳") { t.Errorf("empty cache field still drawn: %q", m2.status()) } }