返回 last30days-skill
run_test.go
根目录 / mcp / internal / engine / run_test.go
1 package engine
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strconv"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 // makeStubPython writes a shell script that simulates python3 and returns
16 // its absolute path. The script honors a small env-driven protocol so each
17 // test can shape its output:
18 //
19 // STUB_STDOUT - text printed to stdout
20 // STUB_STDERR - text printed to stderr
21 // STUB_EXIT_CODE - integer exit code (default 0)
22 // STUB_SLEEP_SECS - sleep before exiting (for timeout tests)
23 // STUB_ECHO_ENV - name of an env var; the stub prints "<NAME>=<VALUE>"
24 // STUB_ECHO_ARG - integer index; the stub prints "ARG<i>=<args[i]>"
25 //
26 // The stub ignores its first argument (the script path), matching how a
27 // real python3 invocation treats `python3 last30days.py ...`.
28 func makeStubPython(t *testing.T) string {
29 t.Helper()
30 if runtime.GOOS == "windows" {
31 t.Skip("stub-python tests rely on POSIX shell")
32 }
33 dir := t.TempDir()
34 path := filepath.Join(dir, "python3-stub.sh")
35 script := `#!/usr/bin/env bash
36 if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi
37 if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi
38 if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi
39 if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-<unset>}"; fi
40 if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-<unset>}"; fi
41 exit "${STUB_EXIT_CODE:-0}"
42 `
43 if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
44 t.Fatalf("write stub: %v", err)
45 }
46 return path
47 }
48
49 // stageCache materializes a fake CacheDir with a no-op last30days.py so
50 // the existence check in Run passes. The stub python3 ignores the script
51 // contents, so the file just has to exist.
52 func stageCache(t *testing.T) string {
53 t.Helper()
54 dir := t.TempDir()
55 if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil {
56 t.Fatalf("stage cache: %v", err)
57 }
58 return dir
59 }
60
61 func TestRunHappyPath(t *testing.T) {
62 stub := makeStubPython(t)
63 cache := stageCache(t)
64 t.Setenv("STUB_STDOUT", "synthesis output\n")
65
66 res, err := Run(context.Background(), RunOptions{
67 PythonPath: stub,
68 CacheDir: cache,
69 Args: []string{"my topic", "--emit=compact"},
70 })
71 if err != nil {
72 t.Fatalf("Run: %v", err)
73 }
74 if string(res.Stdout) != "synthesis output\n" {
75 t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n")
76 }
77 if res.ExitCode != 0 {
78 t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
79 }
80 if res.TimedOut {
81 t.Fatal("TimedOut = true, want false")
82 }
83 }
84
85 func TestRunForwardsEnv(t *testing.T) {
86 stub := makeStubPython(t)
87 cache := stageCache(t)
88 t.Setenv("OPENAI_API_KEY", "sk-test-value")
89 t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY")
90
91 res, err := Run(context.Background(), RunOptions{
92 PythonPath: stub,
93 CacheDir: cache,
94 })
95 if err != nil {
96 t.Fatalf("Run: %v", err)
97 }
98 if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" {
99 t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got)
100 }
101 }
102
103 func TestRunSetsPythonPath(t *testing.T) {
104 stub := makeStubPython(t)
105 cache := stageCache(t)
106 t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
107
108 res, err := Run(context.Background(), RunOptions{
109 PythonPath: stub,
110 CacheDir: cache,
111 })
112 if err != nil {
113 t.Fatalf("Run: %v", err)
114 }
115 want := "PYTHONPATH=" + cache
116 if got := strings.TrimSpace(string(res.Stdout)); got != want {
117 t.Fatalf("stdout = %q, want %q", got, want)
118 }
119 }
120
121 // TestRunDropsPreExistingPythonPath guards the buildEnv dedup: when the
122 // parent already sets PYTHONPATH (common on dev machines and CI runners
123 // that touch Python), the child must NOT see two PYTHONPATH= entries.
124 // POSIX getenv returns the first match, so a duplicate from os.Environ
125 // would shadow our cache-dir entry and break `from lib import ...`.
126 func TestRunDropsPreExistingPythonPath(t *testing.T) {
127 stub := makeStubPython(t)
128 cache := stageCache(t)
129 t.Setenv("PYTHONPATH", "/users-stale-pythonpath")
130 t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
131
132 res, err := Run(context.Background(), RunOptions{
133 PythonPath: stub,
134 CacheDir: cache,
135 })
136 if err != nil {
137 t.Fatalf("Run: %v", err)
138 }
139 got := strings.TrimSpace(string(res.Stdout))
140 want := "PYTHONPATH=" + cache
141 if got != want {
142 t.Fatalf("stdout = %q, want %q (stale parent value leaked through)", got, want)
143 }
144 }
145
146 func TestBuildEnvDropsAllPreExistingPythonPath(t *testing.T) {
147 // Direct unit test on buildEnv to catch the case where the parent has
148 // PYTHONPATH set: the returned slice must contain exactly one
149 // PYTHONPATH= entry, and it must be ours.
150 t.Setenv("PYTHONPATH", "/parent/one")
151 cache := "/cache/dir"
152 out := buildEnv(cache, []string{"EXTRA=1"})
153
154 var pythonPaths []string
155 for _, kv := range out {
156 if strings.HasPrefix(kv, "PYTHONPATH=") {
157 pythonPaths = append(pythonPaths, kv)
158 }
159 }
160 if len(pythonPaths) != 1 {
161 t.Fatalf("got %d PYTHONPATH entries, want 1: %v", len(pythonPaths), pythonPaths)
162 }
163 if pythonPaths[0] != "PYTHONPATH="+cache {
164 t.Fatalf("PYTHONPATH = %q, want %q", pythonPaths[0], "PYTHONPATH="+cache)
165 }
166 // Confirm ExtraEnv still rides along.
167 found := false
168 for _, kv := range out {
169 if kv == "EXTRA=1" {
170 found = true
171 break
172 }
173 }
174 if !found {
175 t.Fatal("EXTRA=1 missing from buildEnv output")
176 }
177 }
178
179 func TestRunSurfacesExitCode(t *testing.T) {
180 stub := makeStubPython(t)
181 cache := stageCache(t)
182 t.Setenv("STUB_STDERR", "engine boom\n")
183 t.Setenv("STUB_EXIT_CODE", "2")
184
185 res, err := Run(context.Background(), RunOptions{
186 PythonPath: stub,
187 CacheDir: cache,
188 })
189 if err == nil {
190 t.Fatal("expected error for non-zero exit")
191 }
192 if res == nil {
193 t.Fatal("res is nil; want populated result alongside error")
194 }
195 if res.ExitCode != 2 {
196 t.Fatalf("ExitCode = %d, want 2", res.ExitCode)
197 }
198 if !strings.Contains(string(res.Stderr), "engine boom") {
199 t.Fatalf("stderr did not surface engine output: %q", res.Stderr)
200 }
201 }
202
203 func TestRunTimesOut(t *testing.T) {
204 stub := makeStubPython(t)
205 cache := stageCache(t)
206 t.Setenv("STUB_SLEEP_SECS", "3")
207
208 res, err := Run(context.Background(), RunOptions{
209 PythonPath: stub,
210 CacheDir: cache,
211 Timeout: 200 * time.Millisecond,
212 })
213 if err == nil {
214 t.Fatal("expected timeout error")
215 }
216 if !res.TimedOut {
217 t.Fatal("TimedOut = false, want true")
218 }
219 if !strings.Contains(err.Error(), "timeout") {
220 t.Fatalf("error %q lacks 'timeout' marker", err)
221 }
222 }
223
224 func TestResolvePythonHonorsEnvOverride(t *testing.T) {
225 executable, err := os.Executable()
226 if err != nil {
227 t.Fatalf("os.Executable: %v", err)
228 }
229 t.Setenv(PythonEnvOverride, executable)
230 t.Setenv("PATH", "")
231
232 got, err := resolvePython("")
233 if err != nil {
234 t.Fatalf("resolvePython: %v", err)
235 }
236 gotInfo, err := os.Stat(got)
237 if err != nil {
238 t.Fatalf("stat resolved path %q: %v", got, err)
239 }
240 wantInfo, err := os.Stat(executable)
241 if err != nil {
242 t.Fatalf("stat override path %q: %v", executable, err)
243 }
244 if !os.SameFile(gotInfo, wantInfo) {
245 t.Fatalf("resolvePython = %q, want executable %q", got, executable)
246 }
247 }
248
249 func TestResolvePythonRejectsInvalidEnvOverride(t *testing.T) {
250 t.Run("missing path", func(t *testing.T) {
251 missing := filepath.Join(t.TempDir(), "missing-python")
252 t.Setenv(PythonEnvOverride, missing)
253
254 _, err := resolvePython("")
255 if err == nil {
256 t.Fatal("expected invalid override error")
257 }
258 if !strings.Contains(err.Error(), PythonEnvOverride) || !strings.Contains(err.Error(), strconv.Quote(missing)) {
259 t.Fatalf("error %q does not identify invalid %s path %q", err, PythonEnvOverride, missing)
260 }
261 })
262
263 t.Run("empty value", func(t *testing.T) {
264 t.Setenv(PythonEnvOverride, "")
265
266 _, err := resolvePython("")
267 if err == nil {
268 t.Fatal("expected empty override error")
269 }
270 if !strings.Contains(err.Error(), PythonEnvOverride) || !strings.Contains(err.Error(), "set but empty") {
271 t.Fatalf("error %q does not clearly identify the empty override", err)
272 }
273 })
274 }
275
276 func TestResolvePythonDefaultsToPython3Lookup(t *testing.T) {
277 dir := t.TempDir()
278 name := DefaultPythonBinary
279 if runtime.GOOS == "windows" {
280 name += ".exe"
281 t.Setenv("PATHEXT", ".COM;.EXE;.BAT;.CMD")
282 }
283 candidate := filepath.Join(dir, name)
284 if err := os.WriteFile(candidate, []byte("stub"), 0o755); err != nil {
285 t.Fatalf("write default python stub: %v", err)
286 }
287 t.Setenv(PythonEnvOverride, "temporarily-set-for-cleanup")
288 if err := os.Unsetenv(PythonEnvOverride); err != nil {
289 t.Fatalf("unset %s: %v", PythonEnvOverride, err)
290 }
291 t.Setenv("PATH", dir)
292
293 got, err := resolvePython("")
294 if err != nil {
295 t.Fatalf("resolvePython: %v", err)
296 }
297 gotInfo, err := os.Stat(got)
298 if err != nil {
299 t.Fatalf("stat resolved path %q: %v", got, err)
300 }
301 wantInfo, err := os.Stat(candidate)
302 if err != nil {
303 t.Fatalf("stat default stub %q: %v", candidate, err)
304 }
305 if !os.SameFile(gotInfo, wantInfo) {
306 t.Fatalf("resolvePython = %q, want python3 lookup result %q", got, candidate)
307 }
308 }
309
310 func TestRunMissingPython(t *testing.T) {
311 cache := stageCache(t)
312 // Empty PATH guarantees the lookup fails. PythonPath stays unset so Run
313 // falls through to exec.LookPath.
314 t.Setenv(PythonEnvOverride, "temporarily-set-for-cleanup")
315 if err := os.Unsetenv(PythonEnvOverride); err != nil {
316 t.Fatalf("unset %s: %v", PythonEnvOverride, err)
317 }
318 t.Setenv("PATH", "")
319
320 _, err := Run(context.Background(), RunOptions{CacheDir: cache})
321 if err == nil {
322 t.Fatal("expected lookup failure with empty PATH")
323 }
324 if !strings.Contains(err.Error(), DefaultPythonBinary) {
325 t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary)
326 }
327 if !strings.Contains(err.Error(), PythonInstallURL) {
328 t.Fatalf("error %q does not include install URL", err)
329 }
330 }
331
332 func TestResolvePythonRejectsRelativePATH(t *testing.T) {
333 if runtime.GOOS == "windows" {
334 t.Skip("POSIX executable fixture")
335 }
336 t.Chdir(t.TempDir())
337 if err := os.WriteFile(DefaultPythonBinary, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
338 t.Fatal(err)
339 }
340 t.Setenv("PATH", ".")
341 // Exercise our own guard even when Go's ErrDot protection is disabled.
342 t.Setenv("GODEBUG", "execerrdot=0")
343 if path, err := resolvePython(""); err == nil || path != "" {
344 t.Fatalf("resolvePython accepted relative executable: path=%q err=%v", path, err)
345 }
346 }
347
348 func TestResolvePythonAcceptsAbsolutePATH(t *testing.T) {
349 if runtime.GOOS == "windows" {
350 t.Skip("POSIX executable fixture")
351 }
352 dir := t.TempDir()
353 want := filepath.Join(dir, DefaultPythonBinary)
354 if err := os.WriteFile(want, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
355 t.Fatal(err)
356 }
357 t.Setenv("PATH", dir)
358 if path, err := resolvePython(""); err != nil || path != want {
359 t.Fatalf("resolvePython = %q, %v; want %q", path, err, want)
360 }
361 }
362
363 func TestResolvePythonPreservesExplicitOverride(t *testing.T) {
364 t.Setenv("PATH", "")
365 want := filepath.Join("explicit", "python")
366 if path, err := resolvePython(want); err != nil || path != want {
367 t.Fatalf("resolvePython = %q, %v; want trusted override %q", path, err, want)
368 }
369 }
370
371 func TestRunMissingScript(t *testing.T) {
372 stub := makeStubPython(t)
373 // CacheDir exists but contains no last30days.py.
374 cache := t.TempDir()
375
376 _, err := Run(context.Background(), RunOptions{
377 PythonPath: stub,
378 CacheDir: cache,
379 })
380 if err == nil {
381 t.Fatal("expected error when last30days.py missing")
382 }
383 if !strings.Contains(err.Error(), "last30days.py") {
384 t.Fatalf("error %q does not name missing script", err)
385 }
386 }
387
388 func TestRunRejectsEmptyCacheDir(t *testing.T) {
389 stub := makeStubPython(t)
390 _, err := Run(context.Background(), RunOptions{PythonPath: stub})
391 if err == nil {
392 t.Fatal("expected error for empty CacheDir")
393 }
394 if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") {
395 t.Fatalf("error %q does not name CacheDir", err)
396 }
397 }
398
399 func TestResolveTimeoutHonorsEnv(t *testing.T) {
400 t.Setenv(TimeoutEnvOverride, "750ms")
401 if got := resolveTimeout(0); got != 750*time.Millisecond {
402 t.Fatalf("resolveTimeout = %v, want 750ms", got)
403 }
404 t.Setenv(TimeoutEnvOverride, "garbage")
405 if got := resolveTimeout(0); got != DefaultTimeout {
406 t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout)
407 }
408 if got := resolveTimeout(time.Minute); got != time.Minute {
409 t.Fatalf("explicit value not honored: got %v", got)
410 }
411 }
412
413 func TestResolveTimeoutBareIntegerSeconds(t *testing.T) {
414 t.Setenv(TimeoutEnvOverride, "300")
415 if got := resolveTimeout(0); got != 300*time.Second {
416 t.Fatalf("bare integer 300: got %v, want 5m0s", got)
417 }
418 t.Setenv(TimeoutEnvOverride, "1")
419 if got := resolveTimeout(0); got != 1*time.Second {
420 t.Fatalf("bare integer 1: got %v, want 1s", got)
421 }
422 t.Setenv(TimeoutEnvOverride, "0")
423 if got := resolveTimeout(0); got != DefaultTimeout {
424 t.Fatalf("bare integer 0: got %v, want default %v", got, DefaultTimeout)
425 }
426 t.Setenv(TimeoutEnvOverride, "-1")
427 if got := resolveTimeout(0); got != DefaultTimeout {
428 t.Fatalf("bare integer -1: got %v, want default %v", got, DefaultTimeout)
429 }
430 }
431
431 lines GO