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}