v0.38.1 969e52a

build_python_test.go RawHistoryFolder

1package agent
2
3import (
4	"os"
5	"path/filepath"
6	"testing"
7
8	"github.com/bnema/zut/packages/agent/tools"
9	"github.com/bnema/zut/packages/core"
10)
11
12func pythonTestEnv(t *testing.T) (project string) {
13	t.Helper()
14	root := t.TempDir()
15	t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
16	t.Setenv("HOME", filepath.Join(root, "home"))
17	t.Setenv("USERPROFILE", filepath.Join(root, "home"))
18	project = filepath.Join(root, "project")
19	if err := os.MkdirAll(project, 0o755); err != nil {
20		t.Fatal(err)
21	}
22	return project
23}
24
25func pythonTestArgs(project string) Args {
26	return Args{
27		Provider:       "ollama",
28		Model:          "any-local-model",
29		CWD:            project,
30		NoLSP:          true,
31		NoSkill:        true,
32		NoContextFiles: true,
33	}
34}
35
36func resolvePython(t *testing.T, args Args) Resolved {
37	t.Helper()
38	resolved, err := Resolve(args, false)
39	if err != nil {
40		t.Fatalf("Resolve: %v", err)
41	}
42	return resolved
43}
44
45func pythonToolFrom(t *testing.T, r Resolved) *tools.PythonTool {
46	t.Helper()
47	tool, ok := r.ToolRegistry["python"]
48	if !ok {
49		t.Fatal("python not registered")
50	}
51	py, ok := tool.(*tools.PythonTool)
52	if !ok {
53		t.Fatalf("python = %T, want *tools.PythonTool", tool)
54	}
55	return py
56}
57
58func TestPythonRegisteredByDefault(t *testing.T) {
59	project := pythonTestEnv(t)
60	resolved := resolvePython(t, pythonTestArgs(project))
61	py := pythonToolFrom(t, resolved)
62	if py.CWD != project {
63		t.Fatalf("python CWD = %q, want %q", py.CWD, project)
64	}
65	if py.Interpreter != "" {
66		t.Fatalf("python Interpreter = %q, want empty default", py.Interpreter)
67	}
68	if py.Sandbox != resolved.Sandbox {
69		t.Fatal("python must share the live sandbox pointer")
70	}
71}
72
73func TestPythonExplicitEmptySelectionDenied(t *testing.T) {
74	project := pythonTestEnv(t)
75	args := pythonTestArgs(project)
76	args.ToolsSet = true
77	args.Tools = []string{}
78	resolved := resolvePython(t, args)
79	if _, ok := resolved.ToolRegistry["python"]; ok {
80		t.Fatal("explicit empty selection must not enable python")
81	}
82	// Other tools retain their historical empty-list behavior.
83	if _, ok := resolved.ToolRegistry["bash"]; !ok {
84		t.Fatal("explicit empty selection must preserve other tools' behavior")
85	}
86}
87
88func TestPythonExplicitInclusionExclusion(t *testing.T) {
89	project := pythonTestEnv(t)
90	included := pythonTestArgs(project)
91	included.Tools = []string{"python"}
92	resolved := resolvePython(t, included)
93	if _, ok := resolved.ToolRegistry["python"]; !ok {
94		t.Fatal("explicit python selection must register python")
95	}
96	if _, ok := resolved.ToolRegistry["bash"]; ok {
97		t.Fatal("explicit python-only selection must not register bash")
98	}
99
100	excluded := pythonTestArgs(project)
101	excluded.Tools = []string{"bash"}
102	resolved = resolvePython(t, excluded)
103	if _, ok := resolved.ToolRegistry["python"]; ok {
104		t.Fatal("bash-only selection must not register python")
105	}
106	if _, ok := resolved.ToolRegistry["bash"]; !ok {
107		t.Fatal("bash-only selection must register bash")
108	}
109}
110
111func TestPythonNoToolsAndPackagedDenial(t *testing.T) {
112	project := pythonTestEnv(t)
113	args := pythonTestArgs(project)
114	args.NoTools = true
115	if resolved := resolvePython(t, args); len(resolved.ToolRegistry) != 0 {
116		t.Fatalf("NoTools registry = %d tools, want empty", len(resolved.ToolRegistry))
117	}
118
119	args = pythonTestArgs(project)
120	args.PermissionSet = &tools.PermissionSet{}
121	resolved := resolvePython(t, args)
122	if _, ok := resolved.ToolRegistry["python"]; ok {
123		t.Fatal("packaged agents must not receive python")
124	}
125
126	// SDK-shaped selections: nil/empty without provenance keeps the default.
127	if !pythonToolAllowed(Args{}) {
128		t.Fatal("omitted selection must allow python")
129	}
130	if pythonToolAllowed(Args{ToolsSet: true}) {
131		t.Fatal("explicit empty selection must deny python")
132	}
133	if !pythonToolAllowed(Args{Tools: []string{"bash", "python"}}) {
134		t.Fatal("explicit inclusion must allow python")
135	}
136}
137
138func TestPythonSummaryPresenceAbsence(t *testing.T) {
139	project := pythonTestEnv(t)
140	resolved := resolvePython(t, pythonTestArgs(project))
141	found := false
142	for _, s := range resolved.ToolSummary {
143		if s.Name == "python" {
144			found = true
145		}
146	}
147	if !found {
148		t.Fatal("default summary must advertise python")
149	}
150
151	args := pythonTestArgs(project)
152	args.Tools = []string{"bash"}
153	resolved = resolvePython(t, args)
154	for _, s := range resolved.ToolSummary {
155		if s.Name == "python" {
156			t.Fatal("bash-only summary must not advertise python")
157		}
158	}
159}
160
161func TestPythonConfigRoundTripAndAssembly(t *testing.T) {
162	project := pythonTestEnv(t)
163	cfg, err := LoadConfig()
164	if err != nil {
165		t.Fatal(err)
166	}
167	cfg.PythonInterpreter = "/opt/py/bin/python"
168	if err := SaveConfig(cfg); err != nil {
169		t.Fatal(err)
170	}
171	loaded, err := LoadConfig()
172	if err != nil {
173		t.Fatal(err)
174	}
175	if loaded.PythonInterpreter != "/opt/py/bin/python" {
176		t.Fatalf("config round-trip = %q", loaded.PythonInterpreter)
177	}
178	resolved := resolvePython(t, pythonTestArgs(project))
179	if got := pythonToolFrom(t, resolved).Interpreter; got != "/opt/py/bin/python" {
180		t.Fatalf("assembled interpreter = %q", got)
181	}
182}
183
184func TestPythonRebuildUsesCurrentCWDAndSandbox(t *testing.T) {
185	root := t.TempDir()
186	t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
187	t.Setenv("HOME", filepath.Join(root, "home"))
188	t.Setenv("USERPROFILE", filepath.Join(root, "home"))
189	dirA := filepath.Join(root, "a")
190	dirB := filepath.Join(root, "b")
191	if err := os.MkdirAll(dirA, 0o755); err != nil {
192		t.Fatal(err)
193	}
194	if err := os.MkdirAll(dirB, 0o755); err != nil {
195		t.Fatal(err)
196	}
197	args := pythonTestArgs(dirA)
198	first := resolvePython(t, args)
199	if pythonToolFrom(t, first).CWD != dirA {
200		t.Fatal("first build must use its own CWD")
201	}
202
203	// A normal rebuild picks up the current CWD rather than stale state,
204	// and UseSandbox keeps the live jail state across rebuilds.
205	args.CWD = dirB
206	second := resolvePython(t, args)
207	if pythonToolFrom(t, second).CWD != dirB {
208		t.Fatalf("rebuild CWD = %q, want %q", pythonToolFrom(t, second).CWD, dirB)
209	}
210	fresh := tools.NewSandbox(dirB)
211	fresh.Lock()
212	second.UseSandbox(fresh)
213	if got := pythonToolFrom(t, second).Sandbox; got != fresh {
214		t.Fatal("UseSandbox must propagate the live sandbox to python")
215	}
216	if !pythonToolFrom(t, second).Sandbox.Locked() {
217		t.Fatal("python must observe the live jail state")
218	}
219}
220
221type pythonExtStub struct {
222	infos []ExtensionToolInfo
223}
224
225func (s pythonExtStub) Tools() []ExtensionToolInfo { return s.infos }
226
227func (s pythonExtStub) NewExtensionTool(info ExtensionToolInfo) core.Tool {
228	return &tools.GrepTool{}
229}
230
231func TestPythonExtensionCannotBypassPolicyExclusion(t *testing.T) {
232	project := pythonTestEnv(t)
233	args := pythonTestArgs(project)
234	args.Tools = []string{"bash"}
235	resolved := resolvePython(t, args)
236	stub := pythonExtStub{infos: []ExtensionToolInfo{
237		{Extension: "x", Name: "python"},
238		{Extension: "x", Name: "weather"},
239	}}
240	resolved.MergeExtensionTools(stub)
241	if _, ok := resolved.ToolRegistry["python"]; ok {
242		t.Fatal("an extension must not reintroduce a policy-excluded python")
243	}
244	if _, ok := resolved.ToolRegistry["weather"]; !ok {
245		t.Fatal("unrelated extension tools must still merge")
246	}
247}
248
249func TestPythonUnavailableInterpreterDoesNotBlockStartup(t *testing.T) {
250	project := pythonTestEnv(t)
251	cfg, err := LoadConfig()
252	if err != nil {
253		t.Fatal(err)
254	}
255	cfg.PythonInterpreter = filepath.Join(project, "missing", "python")
256	if err := SaveConfig(cfg); err != nil {
257		t.Fatal(err)
258	}
259	// Startup succeeds; the missing interpreter fails only on invocation
260	// with an actionable error (covered at the tool boundary).
261	resolved := resolvePython(t, pythonTestArgs(project))
262	pythonToolFrom(t, resolved)
263}

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