|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "encoding/json" |
| 6 | + "errors" |
| 7 | + "io" |
| 8 | + "os" |
| 9 | + "os/exec" |
| 10 | + "path/filepath" |
| 11 | + "runtime" |
| 12 | + "strings" |
| 13 | + "sync" |
| 14 | + "testing" |
| 15 | + "time" |
| 16 | +) |
| 17 | + |
| 18 | +// devkit mcp speaks JSON-RPC over stdout. A single byte of non-JSON on |
| 19 | +// that stream breaks the handshake (Codex saw "connection closed: |
| 20 | +// initialize response"). This test runs the real binary, sends an |
| 21 | +// initialize request, and asserts stdout carries only the JSON-RPC |
| 22 | +// response. |
| 23 | + |
| 24 | +var ( |
| 25 | + binBuildOnce sync.Once |
| 26 | + binBuildPath string |
| 27 | + binBuildErr error |
| 28 | +) |
| 29 | + |
| 30 | +func buildDevkitBinary(t *testing.T) string { |
| 31 | + t.Helper() |
| 32 | + binBuildOnce.Do(func() { |
| 33 | + tmpDir, err := os.MkdirTemp("", "devkit-mcp-test-*") |
| 34 | + if err != nil { |
| 35 | + binBuildErr = err |
| 36 | + return |
| 37 | + } |
| 38 | + name := "devkit-test" |
| 39 | + if runtime.GOOS == "windows" { |
| 40 | + name += ".exe" |
| 41 | + } |
| 42 | + binBuildPath = filepath.Join(tmpDir, name) |
| 43 | + // Build from src/ (parent of cmd/). The test's working |
| 44 | + // directory is the package dir, so "../" resolves to src/. |
| 45 | + cmd := exec.Command("go", "build", "-o", binBuildPath, ".") |
| 46 | + cmd.Dir = ".." |
| 47 | + var stderr bytes.Buffer |
| 48 | + cmd.Stderr = &stderr |
| 49 | + if err := cmd.Run(); err != nil { |
| 50 | + binBuildErr = errors.New("go build failed: " + err.Error() + ": " + stderr.String()) |
| 51 | + } |
| 52 | + }) |
| 53 | + if binBuildErr != nil { |
| 54 | + t.Fatalf("build devkit: %v", binBuildErr) |
| 55 | + } |
| 56 | + return binBuildPath |
| 57 | +} |
| 58 | + |
| 59 | +// TestMCPStdoutIsCleanJSONRPC is a regression test for the stdout |
| 60 | +// contamination bug. Any banner, log line, or stray Println in a code |
| 61 | +// path reachable from `devkit mcp` will fail this test by appearing on |
| 62 | +// stdout before the JSON-RPC response. |
| 63 | +func TestMCPStdoutIsCleanJSONRPC(t *testing.T) { |
| 64 | + if testing.Short() { |
| 65 | + t.Skip("skipping subprocess build in short mode") |
| 66 | + } |
| 67 | + |
| 68 | + bin := buildDevkitBinary(t) |
| 69 | + |
| 70 | + initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` + |
| 71 | + `{"protocolVersion":"2024-11-05","capabilities":{},` + |
| 72 | + `"clientInfo":{"name":"regression-test","version":"0.0.0"}}}` + "\n" |
| 73 | + |
| 74 | + // CLAUDE_PLUGIN_ROOT points the server at this repo's workflows |
| 75 | + // dir; without it, NewServer would fall back to repoRoot and the |
| 76 | + // process needs to be inside a git repo (it is — tests run from |
| 77 | + // the package dir which is inside the devkit checkout). |
| 78 | + repoRoot, err := filepath.Abs("../..") |
| 79 | + if err != nil { |
| 80 | + t.Fatalf("resolve repo root: %v", err) |
| 81 | + } |
| 82 | + |
| 83 | + cmd := exec.Command(bin, "mcp") |
| 84 | + cmd.Env = append(os.Environ(), |
| 85 | + "CLAUDE_PLUGIN_ROOT="+repoRoot, |
| 86 | + "CLAUDE_PLUGIN_DATA="+t.TempDir(), |
| 87 | + ) |
| 88 | + cmd.Stdin = strings.NewReader(initReq) |
| 89 | + var stdout, stderr bytes.Buffer |
| 90 | + cmd.Stdout = &stdout |
| 91 | + cmd.Stderr = &stderr |
| 92 | + |
| 93 | + if err := cmd.Start(); err != nil { |
| 94 | + t.Fatalf("start devkit mcp: %v", err) |
| 95 | + } |
| 96 | + done := make(chan error, 1) |
| 97 | + go func() { done <- cmd.Wait() }() |
| 98 | + |
| 99 | + // The server keeps reading stdin after responding to initialize. |
| 100 | + // Closing stdin (already exhausted) and giving it a bounded wait |
| 101 | + // is enough — if it doesn't exit, kill it. |
| 102 | + select { |
| 103 | + case <-done: |
| 104 | + case <-time.After(10 * time.Second): |
| 105 | + _ = cmd.Process.Kill() |
| 106 | + <-done |
| 107 | + t.Fatalf("devkit mcp did not exit within 10s\nstdout: %q\nstderr: %q", stdout.String(), stderr.String()) |
| 108 | + } |
| 109 | + |
| 110 | + out := stdout.Bytes() |
| 111 | + if len(out) == 0 { |
| 112 | + t.Fatalf("devkit mcp wrote nothing to stdout\nstderr: %q", stderr.String()) |
| 113 | + } |
| 114 | + if out[0] != '{' { |
| 115 | + t.Fatalf("stdout does not start with JSON-RPC object — leading bytes: %q\nfull stdout: %q\nstderr: %q", |
| 116 | + leadingBytes(out, 80), out, stderr.String()) |
| 117 | + } |
| 118 | + |
| 119 | + // Every line on stdout must parse as JSON. A stray Println would |
| 120 | + // land on its own line and fail decoding here. |
| 121 | + dec := json.NewDecoder(bytes.NewReader(out)) |
| 122 | + sawInitResp := false |
| 123 | + for { |
| 124 | + var msg map[string]any |
| 125 | + err := dec.Decode(&msg) |
| 126 | + if errors.Is(err, io.EOF) { |
| 127 | + break |
| 128 | + } |
| 129 | + if err != nil { |
| 130 | + t.Fatalf("stdout contains non-JSON content: %v\nstdout: %q\nstderr: %q", err, out, stderr.String()) |
| 131 | + } |
| 132 | + if id, ok := msg["id"]; ok { |
| 133 | + // JSON numbers decode as float64; the initialize id is 1. |
| 134 | + if n, ok := id.(float64); ok && n == 1 { |
| 135 | + sawInitResp = true |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | + if !sawInitResp { |
| 140 | + t.Fatalf("did not see initialize response on stdout\nstdout: %q\nstderr: %q", out, stderr.String()) |
| 141 | + } |
| 142 | + |
| 143 | + // The diagnostic banner belongs on stderr. This is the affirmative |
| 144 | + // half of the contract — if someone deletes the banner, that's |
| 145 | + // fine; if they move it back to stdout, the JSON check above |
| 146 | + // fails first. |
| 147 | + if strings.Contains(stdout.String(), "devkit MCP server ready") { |
| 148 | + t.Fatalf("ready banner leaked to stdout — must go to stderr\nstdout: %q", stdout.String()) |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +func leadingBytes(b []byte, n int) []byte { |
| 153 | + if len(b) < n { |
| 154 | + return b |
| 155 | + } |
| 156 | + return b[:n] |
| 157 | +} |
0 commit comments