v0.48.1 deaab59

python_exec_unix_test.go RawHistoryFolder

1//go:build !windows
2
3package tools
4
5import (
6	"context"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/bnema/zut/packages/provider"
15)
16
17// writeFakeInterpreter creates an executable shell script acting as a fake
18// Python interpreter. It ignores -u -B - arguments like a real interpreter
19// would ignore unknown flags only if it is python; here the script simply
20// never parses flags, which is sufficient to exercise stdin delivery,
21// merged output, exit codes, and cancellation plumbing.
22func writeFakeInterpreter(t *testing.T, body string) string {
23	t.Helper()
24	path := filepath.Join(t.TempDir(), "fakepy")
25	content := "#!/bin/sh\n" + body + "\n"
26	if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
27		t.Fatal(err)
28	}
29	return path
30}
31
32func execPythonWith(t *testing.T, exe, code string, timeout int64, progress func(string)) (string, bool, map[string]any) {
33	t.Helper()
34	tool := &PythonTool{
35		CWD: t.TempDir(),
36		resolveHook: func(context.Context, string, string) (resolvedPython, error) {
37			return resolvedPython{Path: exe, Version: pythonVersion{Major: 3, Minor: 99, Micro: 0}}, nil
38		},
39	}
40	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": code, "timeout": timeout}), progress)
41	if err != nil {
42		t.Fatal(err)
43	}
44	text := res.Content[0].(provider.TextBlock).Text
45	return text, res.IsError, res.Details.(map[string]any)
46}
47
48func TestPythonStdinDeliveredAndEOFObserved(t *testing.T) {
49	exe := writeFakeInterpreter(t, "cat")
50	text, isErr, _ := execPythonWith(t, exe, "hello stdin\nsecond line", 5, nil)
51	if isErr {
52		t.Fatalf("unexpected error:\n%s", text)
53	}
54	if !strings.Contains(text, "hello stdin") || !strings.Contains(text, "[exit 0]") {
55		t.Fatalf("got:\n%s", text)
56	}
57}
58
59func TestPythonMergedStderrAndNonzeroExit(t *testing.T) {
60	exe := writeFakeInterpreter(t, "cat >/dev/null\necho boom >&2\nexit 3")
61	text, isErr, details := execPythonWith(t, exe, "ignored", 5, nil)
62	if !isErr {
63		t.Fatalf("want IsError:\n%s", text)
64	}
65	if !strings.Contains(text, "boom") || !strings.Contains(text, "[exit 3]") {
66		t.Fatalf("got:\n%s", text)
67	}
68	if details["exit_code"] != 3 {
69		t.Fatalf("details = %#v", details)
70	}
71}
72
73func TestPythonTimeoutBoundsReturn(t *testing.T) {
74	exe := writeFakeInterpreter(t, "cat >/dev/null\nsleep 30")
75	start := time.Now()
76	text, isErr, _ := execPythonWith(t, exe, "x", 1, nil)
77	elapsed := time.Since(start)
78	if !isErr || !strings.Contains(text, "[timed out after 1 second]") {
79		t.Fatalf("got:\n%s", text)
80	}
81	if elapsed > 15*time.Second {
82		t.Fatalf("timeout return took %s; cleanup did not bound the process", elapsed)
83	}
84}
85
86func TestPythonDescendantCannotRetainPipe(t *testing.T) {
87	// A background grandchild inherits the output pipe. Process-group kill
88	// plus pipe closing must still bound the return, and the successful
89	// interpreter exit must not be misreported as an execution failure.
90	exe := writeFakeInterpreter(t, "(sleep 30 &)\ncat >/dev/null\necho quick")
91	start := time.Now()
92	// Generous timeout: the interpreter exits immediately, so WaitDelay
93	// expiry (not the invocation deadline) bounds the return.
94	text, isErr, _ := execPythonWith(t, exe, "x", 30, nil)
95	elapsed := time.Since(start)
96	if !strings.Contains(text, "quick") {
97		t.Fatalf("got:\n%s", text)
98	}
99	if isErr {
100		t.Fatalf("detached descendant must not turn exit 0 into an error:\n%s", text)
101	}
102	if !strings.Contains(text, "[exit 0]") {
103		t.Fatalf("want successful interpreter exit, got:\n%s", text)
104	}
105	if elapsed > 25*time.Second {
106		t.Fatalf("return took %s; descendant retained the pipe", elapsed)
107	}
108}
109
110func TestPythonCancellationWhileSupplyingStdin(t *testing.T) {
111	exe := writeFakeInterpreter(t, "while IFS= read -r line; do sleep 0.2; echo \"$line\"; done")
112	tool := &PythonTool{
113		CWD: t.TempDir(),
114		resolveHook: func(context.Context, string, string) (resolvedPython, error) {
115			return resolvedPython{Path: exe, Version: pythonVersion{Major: 3}}, nil
116		},
117	}
118	big := strings.Repeat("line\n", 500)
119	ctx, cancel := context.WithCancel(context.Background())
120	go func() {
121		time.Sleep(300 * time.Millisecond)
122		cancel()
123	}()
124	start := time.Now()
125	res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"code": big, "timeout": 30}), nil)
126	elapsed := time.Since(start)
127	if err != nil {
128		// Cancellation may surface as a Go error or a cancelled result;
129		// either is acceptable as long as the return is bounded.
130		if elapsed > 15*time.Second {
131			t.Fatalf("cancelled return took %s", elapsed)
132		}
133		return
134	}
135	text := res.Content[0].(provider.TextBlock).Text
136	if !strings.Contains(text, "[cancelled]") && !res.IsError {
137		t.Fatalf("want cancellation status, got:\n%s", text)
138	}
139	if elapsed > 15*time.Second {
140		t.Fatalf("cancelled return took %s", elapsed)
141	}
142}
143
144// realPython3 returns a usable interpreter or skips the test.
145func realPython3(t *testing.T) string {
146	t.Helper()
147	path, err := exec.LookPath("python3")
148	if err != nil {
149		t.Skip("no python3 on PATH; resolver coverage does not require installed Python")
150	}
151	ver, err := probePythonVersion(context.Background(), path)
152	if err != nil || ver.Major != 3 {
153		t.Skipf("python3 probe failed (%v); skipping runtime checks", err)
154	}
155	return path
156}
157
158func TestPythonRealRunsInSessionCWD(t *testing.T) {
159	py := realPython3(t)
160	dir := t.TempDir()
161	tool := &PythonTool{CWD: dir, Interpreter: py}
162	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
163		"code":    "import os; print(os.getcwd())",
164		"timeout": 15,
165	}), nil)
166	if err != nil {
167		t.Fatal(err)
168	}
169	text := res.Content[0].(provider.TextBlock).Text
170	if res.IsError || !strings.Contains(text, dir) {
171		t.Fatalf("got:\n%s", text)
172	}
173	// -B plus stdin execution must not create bytecode caches.
174	entries, _ := os.ReadDir(dir)
175	for _, e := range entries {
176		if e.Name() == "__pycache__" {
177			t.Fatal("execution created __pycache__; -B / stdin execution must not write bytecode")
178		}
179	}
180}
181
182func TestPythonRealImportsAndMissingImport(t *testing.T) {
183	py := realPython3(t)
184	tool := &PythonTool{CWD: t.TempDir(), Interpreter: py}
185	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
186		"code":    "import json; print('imports ok')",
187		"timeout": 15,
188	}), nil)
189	if err != nil {
190		t.Fatal(err)
191	}
192	if res.IsError || !strings.Contains(res.Content[0].(provider.TextBlock).Text, "imports ok") {
193		t.Fatalf("installed import failed:\n%s", res.Content[0].(provider.TextBlock).Text)
194	}
195	res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{
196		"code":    "import no_such_module_xyz_zut",
197		"timeout": 15,
198	}), nil)
199	if err != nil {
200		t.Fatal(err)
201	}
202	text := res.Content[0].(provider.TextBlock).Text
203	if !res.IsError || (!strings.Contains(text, "ModuleNotFoundError") && !strings.Contains(text, "ImportError")) {
204		t.Fatalf("missing import must fail with an ordinary traceback, got:\n%s", text)
205	}
206}
207
208func TestPythonRealNoStateBetweenCalls(t *testing.T) {
209	py := realPython3(t)
210	tool := &PythonTool{CWD: t.TempDir(), Interpreter: py}
211	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": "x_state_probe = 1", "timeout": 15}), nil); err != nil {
212		t.Fatal(err)
213	}
214	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": "print(x_state_probe)", "timeout": 15}), nil)
215	if err != nil {
216		t.Fatal(err)
217	}
218	if !res.IsError || !strings.Contains(res.Content[0].(provider.TextBlock).Text, "NameError") {
219		t.Fatalf("successive calls must not share variables, got:\n%s", res.Content[0].(provider.TextBlock).Text)
220	}
221}
222
223func TestPythonRealWrittenFilesPersist(t *testing.T) {
224	py := realPython3(t)
225	dir := t.TempDir()
226	tool := &PythonTool{CWD: dir, Interpreter: py}
227	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
228		"code":    "open('created.txt','w').write('hi')",
229		"timeout": 15,
230	}), nil)
231	if err != nil {
232		t.Fatal(err)
233	}
234	if res.IsError {
235		t.Fatalf("got:\n%s", res.Content[0].(provider.TextBlock).Text)
236	}
237	b, err := os.ReadFile(filepath.Join(dir, "created.txt"))
238	if err != nil || string(b) != "hi" {
239		t.Fatalf("deliberately written file missing: %q %v", b, err)
240	}
241}

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