v0.46.0 ea0e112

python_test.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"os"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17func pythonArgsJSON(t *testing.T, code string, timeout int64) json.RawMessage {
18	t.Helper()
19	return mustJSON(t, map[string]any{"code": code, "timeout": timeout})
20}
21
22func stubPythonTool(resolved resolvedPython, resolveErr error, outcome pythonExecOutcome, execErr error) *PythonTool {
23	return &PythonTool{
24		CWD: "/session",
25		resolveHook: func(context.Context, string, string) (resolvedPython, error) {
26			return resolved, resolveErr
27		},
28		execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
29			return outcome, execErr
30		},
31	}
32}
33
34func TestPythonSchemaRequiresCodeAndTimeout(t *testing.T) {
35	var schema struct {
36		Properties map[string]struct {
37			Type        string `json:"type"`
38			Minimum     *int64 `json:"minimum"`
39			Maximum     *int64 `json:"maximum"`
40			Description string `json:"description"`
41		} `json:"properties"`
42		Required []string `json:"required"`
43	}
44	if err := json.Unmarshal((&PythonTool{}).Schema(), &schema); err != nil {
45		t.Fatal(err)
46	}
47	timeout, ok := schema.Properties["timeout"]
48	if !ok || timeout.Type != "integer" || timeout.Minimum == nil || *timeout.Minimum != 1 || timeout.Maximum == nil || *timeout.Maximum != maxPythonTimeoutSeconds {
49		t.Fatalf("timeout schema = %+v", timeout)
50	}
51	if _, ok := schema.Properties["code"]; !ok {
52		t.Fatal("code property missing")
53	}
54	required := map[string]bool{}
55	for _, name := range schema.Required {
56		required[name] = true
57	}
58	if !required["code"] || !required["timeout"] {
59		t.Fatalf("required = %v", schema.Required)
60	}
61	if (&PythonTool{}).Name() != "python" {
62		t.Fatalf("name = %q", (&PythonTool{}).Name())
63	}
64}
65
66func TestPythonExecArgvDirect(t *testing.T) {
67	argv := pythonExecArgv("/usr/bin/python3")
68	if len(argv) != 4 || argv[0] != "/usr/bin/python3" || argv[1] != "-u" || argv[2] != "-B" || argv[3] != "-" {
69		t.Fatalf("argv = %q, want [exe -u -B -]", argv)
70	}
71}
72
73func TestPythonRejectsInvalidArgs(t *testing.T) {
74	tool := stubPythonTool(resolvedPython{}, nil, pythonExecOutcome{}, nil)
75	cases := []struct {
76		name string
77		raw  json.RawMessage
78		want string
79	}{
80		{"blank code", mustJSON(t, map[string]any{"code": "  ", "timeout": 5}), "code is required"},
81		{"missing timeout", mustJSON(t, map[string]any{"code": "print(1)"}), "timeout is required"},
82		{"zero timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": 0}), "timeout must be between 1 and"},
83		{"negative timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": -2}), "timeout must be between 1 and"},
84	}
85	for _, tc := range cases {
86		t.Run(tc.name, func(t *testing.T) {
87			res, err := tool.Execute(context.Background(), tc.raw, nil)
88			if err != nil {
89				t.Fatalf("err = %v, want model-visible IsError result", err)
90			}
91			if !res.IsError {
92				t.Fatal("want IsError for invalid args")
93			}
94			if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
95				t.Fatalf("got %q want %q", got, tc.want)
96			}
97		})
98	}
99}
100
101func TestPythonDeniedNeverResolves(t *testing.T) {
102	resolveCalls := 0
103	execCalls := 0
104	mk := func(sandbox *Sandbox) *PythonTool {
105		return &PythonTool{
106			CWD:     "/session",
107			Sandbox: sandbox,
108			resolveHook: func(context.Context, string, string) (resolvedPython, error) {
109				resolveCalls++
110				return resolvedPython{Path: "/usr/bin/python3"}, nil
111			},
112			execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
113				execCalls++
114				return pythonExecOutcome{}, nil
115			},
116		}
117	}
118	jailed := NewSandbox("/session")
119	jailed.Lock()
120	_, err := mk(jailed).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
121	if err == nil || !strings.Contains(err.Error(), "cannot be confined") {
122		t.Fatalf("jail err = %v, want typed confinement refusal", err)
123	}
124	var jailDenied *core.ToolDeniedError
125	if !errors.As(err, &jailDenied) {
126		t.Fatalf("jail err type = %T, want *core.ToolDeniedError", err)
127	}
128	restricted := NewSandbox("/session")
129	restricted.SetPermissions(&PermissionSet{})
130	_, err = mk(restricted).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
131	if err == nil || !strings.Contains(err.Error(), "permission denied") {
132		t.Fatalf("permission err = %v, want typed permission refusal", err)
133	}
134	var permDenied *core.ToolDeniedError
135	if !errors.As(err, &permDenied) {
136		t.Fatalf("permission err type = %T, want *core.ToolDeniedError", err)
137	}
138	if resolveCalls != 0 || execCalls != 0 {
139		t.Fatalf("denied calls must never resolve or execute (resolve=%d exec=%d)", resolveCalls, execCalls)
140	}
141}
142
143func TestPythonMissingInterpreterActionable(t *testing.T) {
144	tool := &PythonTool{
145		CWD: "/session",
146		resolveHook: func(context.Context, string, string) (resolvedPython, error) {
147			return resolvedPython{}, missingPythonError()
148		},
149		execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
150			t.Fatal("missing interpreter must not execute")
151			return pythonExecOutcome{}, nil
152		},
153	}
154	res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
155	if err != nil {
156		t.Fatalf("err = %v, want model-visible IsError result", err)
157	}
158	if !res.IsError {
159		t.Fatal("want IsError for missing interpreter")
160	}
161	got := res.Content[0].(provider.TextBlock).Text
162	if !strings.Contains(got, "existing Python 3") || !strings.Contains(got, "never installs") {
163		t.Fatalf("result = %q, want actionable guidance", got)
164	}
165}
166
167func TestPythonSuccessResult(t *testing.T) {
168	interp := resolvedPython{Path: "/usr/bin/python3", Version: pythonVersion{Major: 3, Minor: 12, Micro: 1}}
169	tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: "hi\n", ExitCode: 0}, nil)
170	res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print('hi')", 5), nil)
171	if err != nil {
172		t.Fatal(err)
173	}
174	if res.IsError {
175		t.Fatal("unexpected IsError")
176	}
177	text := res.Content[0].(provider.TextBlock).Text
178	for _, want := range []string{"python 3.12.1", "/usr/bin/python3", "hi", "[exit 0]"} {
179		if !strings.Contains(text, want) {
180			t.Fatalf("result missing %q:\n%s", want, text)
181		}
182	}
183	details := res.Details.(map[string]any)
184	if details["exit_code"] != 0 || details["interpreter"] != "/usr/bin/python3" || details["python_version"] != "3.12.1" || details["bytes_truncated"] != false || details["lines_truncated"] != false {
185		t.Fatalf("details = %#v", details)
186	}
187	if path, _ := details["full_output_path"].(string); path != "" || strings.Contains(text, "full output") {
188		t.Fatalf("untruncated result must not persist a full-output file: %q", path)
189	}
190}
191
192func TestPythonFailureStatuses(t *testing.T) {
193	interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
194	for _, tc := range []struct {
195		name    string
196		outcome pythonExecOutcome
197		want    string
198	}{
199		{"nonzero", pythonExecOutcome{Captured: "boom\n", ExitCode: 3}, "[exit 3]"},
200		{"timeout", pythonExecOutcome{Captured: "", ExitCode: -1, TimedOut: true}, "[timed out after 5 seconds]"},
201		{"cancelled", pythonExecOutcome{Captured: "", ExitCode: -1, Cancelled: true}, "[cancelled]"},
202	} {
203		t.Run(tc.name, func(t *testing.T) {
204			tool := stubPythonTool(interp, nil, tc.outcome, nil)
205			res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
206			if err != nil {
207				t.Fatal(err)
208			}
209			if !res.IsError {
210				t.Fatal("want IsError")
211			}
212			if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
213				t.Fatalf("got %q want %q", got, tc.want)
214			}
215		})
216	}
217}
218
219func TestPythonCancelErrorCapture(t *testing.T) {
220	var ce pythonCancelError
221	if err := ce.get(); err != nil {
222		t.Fatalf("empty capture = %v, want nil", err)
223	}
224	ce.set(errors.New("kill group: boom"))
225	if err := ce.get(); err == nil || !strings.Contains(err.Error(), "boom") {
226		t.Fatalf("captured = %v, want kill error", err)
227	}
228	// A nil receiver must not panic: probes pass nil explicitly.
229	var nilCE *pythonCancelError
230	nilCE.set(errors.New("ignored"))
231	if err := nilCE.get(); err != nil {
232		t.Fatalf("nil capture = %v, want nil", err)
233	}
234}
235
236func TestPythonTruncationMarkedWithFullOutput(t *testing.T) {
237	interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3, Minor: 1, Micro: 0}}
238	// Exactly maxPythonLines newline-terminated lines must not count as
239	// truncated: the trailing newline terminates the last line.
240	exact := strings.Repeat("x\n", maxPythonLines)
241	tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: exact, ExitCode: 0}, nil)
242	res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
243	if err != nil {
244		t.Fatal(err)
245	}
246	if got := res.Content[0].(provider.TextBlock).Text; strings.Contains(got, "truncated at") {
247		t.Fatalf("exact line budget must not truncate:\n%s", got)
248	}
249	if res.Details.(map[string]any)["lines_truncated"] != false {
250		t.Fatalf("details = %#v", res.Details)
251	}
252	if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path != "" {
253		t.Fatalf("untruncated result must not persist an artifact: %q", path)
254	}
255
256	long := strings.Repeat("x\n", maxPythonLines+10)
257	tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: long, ExitCode: 0}, nil)
258	res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
259	if err != nil {
260		t.Fatal(err)
261	}
262	text := res.Content[0].(provider.TextBlock).Text
263	if !strings.Contains(text, "truncated at 2000 lines") {
264		t.Fatalf("lines truncation not marked:\n%s", text[len(text)-500:])
265	}
266	details := res.Details.(map[string]any)
267	if details["lines_truncated"] != true {
268		t.Fatalf("details = %#v", res.Details)
269	}
270	fullPath, _ := details["full_output_path"].(string)
271	if fullPath == "" || !strings.Contains(text, "full output: "+fullPath) {
272		t.Fatalf("truncated result must reference a full-output file: %q\n%s", fullPath, text)
273	}
274	t.Cleanup(func() { _ = os.Remove(fullPath) })
275	persisted, err := os.ReadFile(fullPath)
276	if err != nil {
277		t.Fatal(err)
278	}
279	if string(persisted) != long {
280		t.Fatal("full-output file must hold the byte-capped buffer beyond line trimming")
281	}
282
283	big := strings.Repeat("y", maxPythonBytes+100)
284	// The stub bypasses capture, so trim the fixture the way the real
285	// runner would: byte-capped, then line-trimmed for display.
286	big = big[:maxPythonBytes]
287	tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: big, ExitCode: 0, BytesTrunc: true}, nil)
288	res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
289	if err != nil {
290		t.Fatal(err)
291	}
292	if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "truncated at 51200 bytes") {
293		t.Fatalf("bytes truncation not marked")
294	}
295	if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path == "" {
296		t.Fatal("byte-truncated result must persist a full-output file")
297	} else {
298		t.Cleanup(func() { _ = os.Remove(path) })
299	}
300}
301
302func TestPythonProgressStreamsMergedOutput(t *testing.T) {
303	interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
304	var calls []string
305	tool := &PythonTool{
306		CWD: "/session",
307		resolveHook: func(context.Context, string, string) (resolvedPython, error) {
308			return interp, nil
309		},
310		execHook: func(_ context.Context, _, _, _ string, progress func(string)) (pythonExecOutcome, error) {
311			progress("out\n")
312			progress("err\n")
313			return pythonExecOutcome{Captured: "out\nerr\n", ExitCode: 0}, nil
314		},
315	}
316	res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), func(s string) {
317		calls = append(calls, s)
318	})
319	if err != nil {
320		t.Fatal(err)
321	}
322	if strings.Join(calls, "") != "out\nerr\n" {
323		t.Fatalf("progress = %q", calls)
324	}
325	if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "out") || !strings.Contains(got, "err") {
326		t.Fatalf("merged output missing:\n%s", got)
327	}
328}
329
330func TestAppendPythonChunkBoundaries(t *testing.T) {
331	var buf bytes.Buffer
332	if appendPythonChunk(&buf, bytes.Repeat([]byte("y"), maxPythonBytes)) {
333		t.Fatal("output exactly filling the budget must not report truncation")
334	}
335	if buf.Len() != maxPythonBytes {
336		t.Fatalf("buffer len = %d, want %d", buf.Len(), maxPythonBytes)
337	}
338	// One byte over discards exactly that byte.
339	if !appendPythonChunk(&buf, []byte("!")) {
340		t.Fatal("overflow byte must report truncation")
341	}
342	if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] == '!' {
343		t.Fatal("overflow byte must not be retained")
344	}
345	// A chunk straddling the boundary keeps its fitting prefix and
346	// reports the discarded remainder.
347	buf.Reset()
348	if appendPythonChunk(&buf, bytes.Repeat([]byte("a"), maxPythonBytes-1)) {
349		t.Fatal("sub-budget write must not report truncation")
350	}
351	if !appendPythonChunk(&buf, []byte("bc")) {
352		t.Fatal("straddling write must report its discarded remainder")
353	}
354	if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] != 'b' {
355		t.Fatal("straddling write must retain only its fitting prefix")
356	}
357	if appendPythonChunk(&buf, nil) {
358		t.Fatal("empty chunk must not report truncation")
359	}
360}
361
362func TestPythonTimeoutBounds(t *testing.T) {
363	if _, err := pythonTimeoutDuration(0); err == nil {
364		t.Fatal("want error for 0")
365	}
366	if _, err := pythonTimeoutDuration(maxPythonTimeoutSeconds + 1); err == nil {
367		t.Fatal("want error above maximum")
368	}
369	if d, err := pythonTimeoutDuration(2); err != nil || d != 2*time.Second {
370		t.Fatalf("d = %s, err = %v", d, err)
371	}
372}
373
374func TestPythonResolutionSharesTimeoutBudget(t *testing.T) {
375	interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
376	tool := &PythonTool{
377		CWD: "/session",
378		resolveHook: func(ctx context.Context, _ string, _ string) (resolvedPython, error) {
379			if _, ok := ctx.Deadline(); !ok {
380				t.Fatal("resolution must share the invocation deadline")
381			}
382			return interp, nil
383		},
384		execHook: func(ctx context.Context, _, _, _ string, _ func(string)) (pythonExecOutcome, error) {
385			if _, ok := ctx.Deadline(); !ok {
386				t.Fatal("execution must share the invocation deadline")
387			}
388			return pythonExecOutcome{ExitCode: 0}, nil
389		},
390	}
391	if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err != nil {
392		t.Fatal(err)
393	}
394}
395
396func TestPythonExecErrorPropagates(t *testing.T) {
397	tool := stubPythonTool(
398		resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}},
399		nil, pythonExecOutcome{}, errors.New("start: boom"),
400	)
401	if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err == nil || !strings.Contains(err.Error(), "boom") {
402		t.Fatalf("err = %v", err)
403	}
404}

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 477

↑↓ moveenter openesc close