v0.2.97 d25ea2b

status_test.go RawHistoryFolder

1package main
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"io"
9	"log"
10	"strings"
11	"testing"
12	"testing/iotest"
13	"unicode/utf8"
14
15	"github.com/mark3labs/mcp-go/mcp"
16)
17
18func TestManagedServerStatusCompact(t *testing.T) {
19	s := newManagedServer("grep", ServerConfig{}, "", log.New(io.Discard, "", 0))
20
21	s.state = stateReady
22	s.tools = []mcp.Tool{{Name: "searchGitHub"}}
23	if got, want := s.status(), "grep (1 tool)"; got != want {
24		t.Fatalf("ready status = %q, want %q", got, want)
25	}
26
27	s.state = stateStopped
28	if got, want := s.status(), "grep (sleeping, 1 tool)"; got != want {
29		t.Fatalf("stopped status = %q, want %q", got, want)
30	}
31
32	s.state = stateError
33	s.startErr = errors.New("authorization required")
34	if got, want := s.status(), "grep (auth failed)"; got != want {
35		t.Fatalf("error status = %q, want %q", got, want)
36	}
37}
38
39func TestFormatStatusSummaryCompact(t *testing.T) {
40	b := &bridge{servers: map[string]*managedServer{}}
41	b.servers["grep"] = newManagedServer("grep", ServerConfig{}, "", log.New(io.Discard, "", 0))
42	b.servers["grep"].state = stateReady
43	b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
44	b.servers["n8n-mcp"] = newManagedServer("n8n-mcp", ServerConfig{}, "", log.New(io.Discard, "", 0))
45	b.servers["n8n-mcp"].state = stateReady
46	b.servers["n8n-mcp"].tools = make([]mcp.Tool, 26)
47
48	if got, want := formatStatusSummary(b), "grep (1 tool) | n8n-mcp (26 tools)"; got != want {
49		t.Fatalf("summary = %q, want %q", got, want)
50	}
51	if got, want := b.notifyLevel(), "success"; got != want {
52		t.Fatalf("notify level = %q, want %q", got, want)
53	}
54}
55
56func TestManagedServerStopBeforeStartDoesNotPanic(t *testing.T) {
57	s := newManagedServer("never-started", ServerConfig{}, "", log.New(io.Discard, "", 0))
58	s.stop()
59	if got, want := s.state, stateStopped; got != want {
60		t.Fatalf("state = %s, want %s", got, want)
61	}
62}
63
64func TestMCPToolSchemaPreservesExtraFields(t *testing.T) {
65	schema := mcpToolSchema(mcp.Tool{
66		Name: "query",
67		InputSchema: mcp.ToolInputSchema{
68			Type:                 "object",
69			Properties:           map[string]any{"sql": map[string]any{"type": "string"}},
70			Required:             []string{"sql"},
71			Defs:                 map[string]any{"Thing": map[string]any{"type": "object"}},
72			AdditionalProperties: false,
73		},
74	})
75
76	data, err := json.Marshal(schema)
77	if err != nil {
78		t.Fatalf("marshal schema: %v", err)
79	}
80	var got map[string]any
81	if err := json.Unmarshal(data, &got); err != nil {
82		t.Fatalf("unmarshal schema: %v", err)
83	}
84	if _, ok := got["$defs"]; !ok {
85		t.Fatalf("schema did not preserve $defs: %#v", got)
86	}
87	if got["additionalProperties"] != false {
88		t.Fatalf("schema did not preserve additionalProperties=false: %#v", got)
89	}
90}
91
92func TestCompactErrShortensConnectionFailures(t *testing.T) {
93	msg := "initialize: transport error: failed to send request: failed to send request"
94	if got, want := compactErr(msg), "connection failed"; got != want {
95		t.Fatalf("compactErr = %q, want %q", got, want)
96	}
97}
98
99func TestNotifyLevelWarnOnPartialFailure(t *testing.T) {
100	b := &bridge{servers: map[string]*managedServer{}}
101	b.servers["grep"] = newManagedServer("grep", ServerConfig{}, "", log.New(io.Discard, "", 0))
102	b.servers["grep"].state = stateReady
103	b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
104	b.servers["broken"] = newManagedServer("broken", ServerConfig{}, "", log.New(io.Discard, "", 0))
105	b.servers["broken"].state = stateError
106	b.servers["broken"].startErr = errors.New("authorization required\nmore detail")
107
108	if got, want := formatStatusSummary(b), "broken (auth failed) | grep (1 tool)"; got != want {
109		t.Fatalf("summary = %q, want %q", got, want)
110	}
111	if got, want := b.notifyLevel(), "warn"; got != want {
112		t.Fatalf("notify level = %q, want %q", got, want)
113	}
114}
115
116func TestStopDuringStartDiscardsStaleResult(t *testing.T) {
117	s := newManagedServer("racy", ServerConfig{}, "", log.New(io.Discard, "", 0))
118
119	// Simulate the window inside start(): state is starting, gen recorded.
120	s.mu.Lock()
121	s.state = stateStarting
122	gen := s.gen
123	s.mu.Unlock()
124
125	// stop() arrives while the spawn is in flight.
126	s.stop()
127
128	// The in-flight attempt now tries to commit with a stale generation.
129	err := s.finishStart(gen, nil, []mcp.Tool{{Name: "late"}}, nil)
130	if err == nil {
131		t.Fatal("expected error committing a stale start")
132	}
133
134	s.mu.Lock()
135	defer s.mu.Unlock()
136	if s.state != stateStopped {
137		t.Fatalf("state = %s, want stopped", s.state)
138	}
139	if s.client != nil {
140		t.Fatal("stale client must not be installed")
141	}
142}
143
144func TestCallToolNilClientReturnsErrorNotPanic(t *testing.T) {
145	s := newManagedServer("bogus", ServerConfig{Transport: "no-such-transport", RequestTimeout: 1, ConnectTimeout: 1}, "", log.New(io.Discard, "", 0))
146	_, err := s.callTool(context.Background(), "anything", nil)
147	if err == nil {
148		t.Fatal("expected error from unknown transport")
149	}
150}
151
152func TestPipeStderrKeepsLinesIntactAcrossChunks(t *testing.T) {
153	var buf bytes.Buffer
154	s := newManagedServer("srv", ServerConfig{}, "", log.New(&buf, "", 0))
155
156	// iotest.OneByteReader forces 1-byte reads: the old 4096-byte
157	// chunking code would log every byte as its own "line".
158	long := strings.Repeat("x", 100)
159	r := iotest.OneByteReader(strings.NewReader("first " + long + "\nsecond line\n"))
160	s.pipeStderr(r)
161
162	out := buf.String()
163	if !strings.Contains(out, "[srv:stderr] first "+long+"\n") {
164		t.Fatalf("first line was split or lost:\n%s", out)
165	}
166	if !strings.Contains(out, "[srv:stderr] second line\n") {
167		t.Fatalf("second line missing:\n%s", out)
168	}
169	if got := strings.Count(out, "[srv:stderr]"); got != 2 {
170		t.Fatalf("logged %d lines, want 2:\n%s", got, out)
171	}
172}
173
174func TestCompactErrTruncatesOnRuneBoundary(t *testing.T) {
175	msg := strings.Repeat("ü", 60) // 120 bytes, 60 runes
176	got := compactErr(msg)
177	if !utf8.ValidString(got) {
178		t.Fatalf("truncation split a rune: %q", got)
179	}
180	if !strings.HasSuffix(got, "…") {
181		t.Fatalf("expected ellipsis suffix: %q", got)
182	}
183}
184
185func TestCompactErrClassifications(t *testing.T) {
186	cases := []struct{ in, want string }{
187		{"context deadline exceeded", "timeout"},
188		{"401 Unauthorized", "auth failed"},
189		{"exec: \"nope\": executable file not found in $PATH", "command not found"},
190		{"initialize: something odd", "initialize failed"},
191		{"list tools: something odd", "tool discovery failed"},
192		{"", "error"},
193	}
194	for _, c := range cases {
195		if got := compactErr(c.in); got != c.want {
196			t.Errorf("compactErr(%q) = %q, want %q", c.in, got, c.want)
197		}
198	}
199}
200
201func TestMCPResultToZot(t *testing.T) {
202	if got := mcpResultToZot(nil); len(got.Content) == 0 || got.IsError {
203		t.Fatalf("nil result: %+v", got)
204	}
205
206	res := &mcp.CallToolResult{
207		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "hello"}},
208		IsError: true,
209	}
210	got := mcpResultToZot(res)
211	if !got.IsError {
212		t.Fatal("IsError not propagated")
213	}
214	if len(got.Content) != 1 {
215		t.Fatalf("content count = %d, want 1", len(got.Content))
216	}
217
218	empty := mcpResultToZot(&mcp.CallToolResult{})
219	if len(empty.Content) == 0 {
220		t.Fatal("empty MCP result must produce placeholder content")
221	}
222}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close