返回 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 "strings"
10 "testing"
11 "time"
12 )
13
14 // makeStubPython writes a shell script that simulates python3 and returns
15 // its absolute path. The script honors a small env-driven protocol so each
16 // test can shape its output:
17 //
18 // STUB_STDOUT - text printed to stdout
19 // STUB_STDERR - text printed to stderr
20 // STUB_EXIT_CODE - integer exit code (default 0)
21 // STUB_SLEEP_SECS - sleep before exiting (for timeout tests)
22 // STUB_ECHO_ENV - name of an env var; the stub prints "<NAME>=<VALUE>"
23 // STUB_ECHO_ARG - integer index; the stub prints "ARG<i>=<args[i]>"
24 //
25 // The stub ignores its first argument (the script path), matching how a
26 // real python3 invocation treats `python3 last30days.py ...`.
27 func makeStubPython(t *testing.T) string {
28 t.Helper()
29 if runtime.GOOS == "windows" {
30 t.Skip("stub-python tests rely on POSIX shell")
31 }
32 dir := t.TempDir()
33 path := filepath.Join(dir, "python3-stub.sh")
34 script := `#!/usr/bin/env bash
35 if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi
36 if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi
37 if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi
38 if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-<unset>}"; fi
39 if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-<unset>}"; fi
40 exit "${STUB_EXIT_CODE:-0}"
41 `
42 if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
43 t.Fatalf("write stub: %v", err)
44 }
45 return path
46 }
47
48 // stageCache materializes a fake CacheDir with a no-op last30days.py so
49 // the existence check in Run passes. The stub python3 ignores the script
50 // contents, so the file just has to exist.
51 func stageCache(t *testing.T) string {
52 t.Helper()
53 dir := t.TempDir()
54 if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil {
55 t.Fatalf("stage cache: %v", err)
56 }
57 return dir
58 }
59
60 func TestRunHappyPath(t *testing.T) {
61 stub := makeStubPython(t)
62 cache := stageCache(t)
63 t.Setenv("STUB_STDOUT", "synthesis output\n")
64
65 res, err := Run(context.Background(), RunOptions{
66 PythonPath: stub,
67 CacheDir: cache,
68 Args: []string{"my topic", "--emit=compact"},
69 })
70 if err != nil {
71 t.Fatalf("Run: %v", err)
72 }
73 if string(res.Stdout) != "synthesis output\n" {
74 t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n")
75 }
76 if res.ExitCode != 0 {
77 t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
78 }
79 if res.TimedOut {
80 t.Fatal("TimedOut = true, want false")
81 }
82 }
83
84 func TestRunForwardsEnv(t *testing.T) {
85 stub := makeStubPython(t)
86 cache := stageCache(t)
87 t.Setenv("OPENAI_API_KEY", "sk-test-value")
88 t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY")
89
90 res, err := Run(context.Background(), RunOptions{
91 PythonPath: stub,
92 CacheDir: cache,
93 })
94 if err != nil {
95 t.Fatalf("Run: %v", err)
96 }
97 if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" {
98 t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got)
99 }
100 }
101
102 func TestRunSetsPythonPath(t *testing.T) {
103 stub := makeStubPython(t)
104 cache := stageCache(t)
105 t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
106
107 res, err := Run(context.Background(), RunOptions{
108 PythonPath: stub,
109 CacheDir: cache,
110 })
111 if err != nil {
112 t.Fatalf("Run: %v", err)
113 }
114 want := "PYTHONPATH=" + cache
115 if got := strings.TrimSpace(string(res.Stdout)); got != want {
116 t.Fatalf("stdout = %q, want %q", got, want)
117 }
118 }
119
120 // TestRunDropsPreExistingPythonPath guards the buildEnv dedup: when the
121 // parent already sets PYTHONPATH (common on dev machines and CI runners
122 // that touch Python), the child must NOT see two PYTHONPATH= entries.
123 // POSIX getenv returns the first match, so a duplicate from os.Environ
124 // would shadow our cache-dir entry and break `from lib import ...`.
125 func TestRunDropsPreExistingPythonPath(t *testing.T) {
126 stub := makeStubPython(t)
127 cache := stageCache(t)
128 t.Setenv("PYTHONPATH", "/users-stale-pythonpath")
129 t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
130
131 res, err := Run(context.Background(), RunOptions{
132 PythonPath: stub,
133 CacheDir: cache,
134 })
135 if err != nil {
136 t.Fatalf("Run: %v", err)
137 }
138 got := strings.TrimSpace(string(res.Stdout))
139 want := "PYTHONPATH=" + cache
140 if got != want {
141 t.Fatalf("stdout = %q, want %q (stale parent value leaked through)", got, want)
142 }
143 }
144
145 func TestBuildEnvDropsAllPreExistingPythonPath(t *testing.T) {
146 // Direct unit test on buildEnv to catch the case where the parent has
147 // PYTHONPATH set: the returned slice must contain exactly one
148 // PYTHONPATH= entry, and it must be ours.
149 t.Setenv("PYTHONPATH", "/parent/one")
150 cache := "/cache/dir"
151 out := buildEnv(cache, []string{"EXTRA=1"})
152
153 var pythonPaths []string
154 for _, kv := range out {
155 if strings.HasPrefix(kv, "PYTHONPATH=") {
156 pythonPaths = append(pythonPaths, kv)
157 }
158 }
159 if len(pythonPaths) != 1 {
160 t.Fatalf("got %d PYTHONPATH entries, want 1: %v", len(pythonPaths), pythonPaths)
161 }
162 if pythonPaths[0] != "PYTHONPATH="+cache {
163 t.Fatalf("PYTHONPATH = %q, want %q", pythonPaths[0], "PYTHONPATH="+cache)
164 }
165 // Confirm ExtraEnv still rides along.
166 found := false
167 for _, kv := range out {
168 if kv == "EXTRA=1" {
169 found = true
170 break
171 }
172 }
173 if !found {
174 t.Fatal("EXTRA=1 missing from buildEnv output")
175 }
176 }
177
178 func TestRunSurfacesExitCode(t *testing.T) {
179 stub := makeStubPython(t)
180 cache := stageCache(t)
181 t.Setenv("STUB_STDERR", "engine boom\n")
182 t.Setenv("STUB_EXIT_CODE", "2")
183
184 res, err := Run(context.Background(), RunOptions{
185 PythonPath: stub,
186 CacheDir: cache,
187 })
188 if err == nil {
189 t.Fatal("expected error for non-zero exit")
190 }
191 if res == nil {
192 t.Fatal("res is nil; want populated result alongside error")
193 }
194 if res.ExitCode != 2 {
195 t.Fatalf("ExitCode = %d, want 2", res.ExitCode)
196 }
197 if !strings.Contains(string(res.Stderr), "engine boom") {
198 t.Fatalf("stderr did not surface engine output: %q", res.Stderr)
199 }
200 }
201
202 func TestRunTimesOut(t *testing.T) {
203 stub := makeStubPython(t)
204 cache := stageCache(t)
205 t.Setenv("STUB_SLEEP_SECS", "3")
206
207 res, err := Run(context.Background(), RunOptions{
208 PythonPath: stub,
209 CacheDir: cache,
210 Timeout: 200 * time.Millisecond,
211 })
212 if err == nil {
213 t.Fatal("expected timeout error")
214 }
215 if !res.TimedOut {
216 t.Fatal("TimedOut = false, want true")
217 }
218 if !strings.Contains(err.Error(), "timeout") {
219 t.Fatalf("error %q lacks 'timeout' marker", err)
220 }
221 }
222
223 func TestRunMissingPython(t *testing.T) {
224 cache := stageCache(t)
225 // Empty PATH guarantees the lookup fails. PythonPath stays unset so Run
226 // falls through to exec.LookPath.
227 t.Setenv("PATH", "")
228
229 _, err := Run(context.Background(), RunOptions{CacheDir: cache})
230 if err == nil {
231 t.Fatal("expected lookup failure with empty PATH")
232 }
233 if !strings.Contains(err.Error(), DefaultPythonBinary) {
234 t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary)
235 }
236 if !strings.Contains(err.Error(), PythonInstallURL) {
237 t.Fatalf("error %q does not include install URL", err)
238 }
239 }
240
241 func TestRunMissingScript(t *testing.T) {
242 stub := makeStubPython(t)
243 // CacheDir exists but contains no last30days.py.
244 cache := t.TempDir()
245
246 _, err := Run(context.Background(), RunOptions{
247 PythonPath: stub,
248 CacheDir: cache,
249 })
250 if err == nil {
251 t.Fatal("expected error when last30days.py missing")
252 }
253 if !strings.Contains(err.Error(), "last30days.py") {
254 t.Fatalf("error %q does not name missing script", err)
255 }
256 }
257
258 func TestRunRejectsEmptyCacheDir(t *testing.T) {
259 stub := makeStubPython(t)
260 _, err := Run(context.Background(), RunOptions{PythonPath: stub})
261 if err == nil {
262 t.Fatal("expected error for empty CacheDir")
263 }
264 if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") {
265 t.Fatalf("error %q does not name CacheDir", err)
266 }
267 }
268
269 func TestResolveTimeoutHonorsEnv(t *testing.T) {
270 t.Setenv(TimeoutEnvOverride, "750ms")
271 if got := resolveTimeout(0); got != 750*time.Millisecond {
272 t.Fatalf("resolveTimeout = %v, want 750ms", got)
273 }
274 t.Setenv(TimeoutEnvOverride, "garbage")
275 if got := resolveTimeout(0); got != DefaultTimeout {
276 t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout)
277 }
278 if got := resolveTimeout(time.Minute); got != time.Minute {
279 t.Fatalf("explicit value not honored: got %v", got)
280 }
281 }
282
283 func TestResolveTimeoutBareIntegerSeconds(t *testing.T) {
284 t.Setenv(TimeoutEnvOverride, "300")
285 if got := resolveTimeout(0); got != 300*time.Second {
286 t.Fatalf("bare integer 300: got %v, want 5m0s", got)
287 }
288 t.Setenv(TimeoutEnvOverride, "1")
289 if got := resolveTimeout(0); got != 1*time.Second {
290 t.Fatalf("bare integer 1: got %v, want 1s", got)
291 }
292 t.Setenv(TimeoutEnvOverride, "0")
293 if got := resolveTimeout(0); got != DefaultTimeout {
294 t.Fatalf("bare integer 0: got %v, want default %v", got, DefaultTimeout)
295 }
296 t.Setenv(TimeoutEnvOverride, "-1")
297 if got := resolveTimeout(0); got != DefaultTimeout {
298 t.Fatalf("bare integer -1: got %v, want default %v", got, DefaultTimeout)
299 }
300 }
301
301 lines GO