package engine import ( "context" "errors" "os" "path/filepath" "runtime" "strings" "testing" "time" ) // makeStubPython writes a shell script that simulates python3 and returns // its absolute path. The script honors a small env-driven protocol so each // test can shape its output: // // STUB_STDOUT - text printed to stdout // STUB_STDERR - text printed to stderr // STUB_EXIT_CODE - integer exit code (default 0) // STUB_SLEEP_SECS - sleep before exiting (for timeout tests) // STUB_ECHO_ENV - name of an env var; the stub prints "=" // STUB_ECHO_ARG - integer index; the stub prints "ARG=" // // The stub ignores its first argument (the script path), matching how a // real python3 invocation treats `python3 last30days.py ...`. func makeStubPython(t *testing.T) string { t.Helper() if runtime.GOOS == "windows" { t.Skip("stub-python tests rely on POSIX shell") } dir := t.TempDir() path := filepath.Join(dir, "python3-stub.sh") script := `#!/usr/bin/env bash if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-}"; fi if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-}"; fi exit "${STUB_EXIT_CODE:-0}" ` if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatalf("write stub: %v", err) } return path } // stageCache materializes a fake CacheDir with a no-op last30days.py so // the existence check in Run passes. The stub python3 ignores the script // contents, so the file just has to exist. func stageCache(t *testing.T) string { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil { t.Fatalf("stage cache: %v", err) } return dir } func TestRunHappyPath(t *testing.T) { stub := makeStubPython(t) cache := stageCache(t) t.Setenv("STUB_STDOUT", "synthesis output\n") res, err := Run(context.Background(), RunOptions{ PythonPath: stub, CacheDir: cache, Args: []string{"my topic", "--emit=compact"}, }) if err != nil { t.Fatalf("Run: %v", err) } if string(res.Stdout) != "synthesis output\n" { t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n") } if res.ExitCode != 0 { t.Fatalf("ExitCode = %d, want 0", res.ExitCode) } if res.TimedOut { t.Fatal("TimedOut = true, want false") } } func TestRunForwardsEnv(t *testing.T) { stub := makeStubPython(t) cache := stageCache(t) t.Setenv("OPENAI_API_KEY", "sk-test-value") t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY") res, err := Run(context.Background(), RunOptions{ PythonPath: stub, CacheDir: cache, }) if err != nil { t.Fatalf("Run: %v", err) } if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" { t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got) } } func TestRunSetsPythonPath(t *testing.T) { stub := makeStubPython(t) cache := stageCache(t) t.Setenv("STUB_ECHO_ENV", "PYTHONPATH") res, err := Run(context.Background(), RunOptions{ PythonPath: stub, CacheDir: cache, }) if err != nil { t.Fatalf("Run: %v", err) } want := "PYTHONPATH=" + cache if got := strings.TrimSpace(string(res.Stdout)); got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunSurfacesExitCode(t *testing.T) { stub := makeStubPython(t) cache := stageCache(t) t.Setenv("STUB_STDERR", "engine boom\n") t.Setenv("STUB_EXIT_CODE", "2") res, err := Run(context.Background(), RunOptions{ PythonPath: stub, CacheDir: cache, }) if err == nil { t.Fatal("expected error for non-zero exit") } if res == nil { t.Fatal("res is nil; want populated result alongside error") } if res.ExitCode != 2 { t.Fatalf("ExitCode = %d, want 2", res.ExitCode) } if !strings.Contains(string(res.Stderr), "engine boom") { t.Fatalf("stderr did not surface engine output: %q", res.Stderr) } } func TestRunTimesOut(t *testing.T) { stub := makeStubPython(t) cache := stageCache(t) t.Setenv("STUB_SLEEP_SECS", "3") res, err := Run(context.Background(), RunOptions{ PythonPath: stub, CacheDir: cache, Timeout: 200 * time.Millisecond, }) if err == nil { t.Fatal("expected timeout error") } if !res.TimedOut { t.Fatal("TimedOut = false, want true") } if !strings.Contains(err.Error(), "timeout") { t.Fatalf("error %q lacks 'timeout' marker", err) } } func TestRunMissingPython(t *testing.T) { cache := stageCache(t) // Empty PATH guarantees the lookup fails. PythonPath stays unset so Run // falls through to exec.LookPath. t.Setenv("PATH", "") _, err := Run(context.Background(), RunOptions{CacheDir: cache}) if err == nil { t.Fatal("expected lookup failure with empty PATH") } if !strings.Contains(err.Error(), DefaultPythonBinary) { t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary) } if !strings.Contains(err.Error(), PythonInstallURL) { t.Fatalf("error %q does not include install URL", err) } } func TestRunMissingScript(t *testing.T) { stub := makeStubPython(t) // CacheDir exists but contains no last30days.py. cache := t.TempDir() _, err := Run(context.Background(), RunOptions{ PythonPath: stub, CacheDir: cache, }) if err == nil { t.Fatal("expected error when last30days.py missing") } if !strings.Contains(err.Error(), "last30days.py") { t.Fatalf("error %q does not name missing script", err) } } func TestRunRejectsEmptyCacheDir(t *testing.T) { stub := makeStubPython(t) _, err := Run(context.Background(), RunOptions{PythonPath: stub}) if err == nil { t.Fatal("expected error for empty CacheDir") } if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") { t.Fatalf("error %q does not name CacheDir", err) } } func TestResolveTimeoutHonorsEnv(t *testing.T) { t.Setenv(TimeoutEnvOverride, "750ms") if got := resolveTimeout(0); got != 750*time.Millisecond { t.Fatalf("resolveTimeout = %v, want 750ms", got) } t.Setenv(TimeoutEnvOverride, "garbage") if got := resolveTimeout(0); got != DefaultTimeout { t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout) } if got := resolveTimeout(time.Minute); got != time.Minute { t.Fatalf("explicit value not honored: got %v", got) } }