| 1 | package conformance |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "encoding/json" |
| 7 | "io" |
| 8 | "os/exec" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/extension/protocol" |
| 15 | "reasonix/internal/extension/rpcwire" |
| 16 | ) |
| 17 | |
| 18 | // rawSidecar drives the example binary directly over hand-written JSON-RPC |
| 19 | // frames, for the transport-level conformance cases the typed host client |
| 20 | // cannot produce (unregistered methods, oversized frames, exit statuses). |
| 21 | type rawSidecar struct { |
| 22 | t *testing.T |
| 23 | cmd *exec.Cmd |
| 24 | stdin io.WriteCloser |
| 25 | stdout *bufio.Reader |
| 26 | stderr *bytes.Buffer |
| 27 | |
| 28 | nextID int64 |
| 29 | waitOnce sync.Once |
| 30 | waitErr error |
| 31 | } |
| 32 | |
| 33 | type rawFrame struct { |
| 34 | ID json.RawMessage `json:"id"` |
| 35 | Method string `json:"method"` |
| 36 | Result json.RawMessage `json:"result"` |
| 37 | Error *struct { |
| 38 | Code int `json:"code"` |
| 39 | Message string `json:"message"` |
| 40 | Data json.RawMessage `json:"data"` |
| 41 | } `json:"error"` |
| 42 | } |
| 43 | |
| 44 | func startRawSidecar(t *testing.T) *rawSidecar { |
| 45 | t.Helper() |
| 46 | cmd := exec.Command(examplePath) |
| 47 | stdin, err := cmd.StdinPipe() |
| 48 | if err != nil { |
| 49 | t.Fatalf("StdinPipe: %v", err) |
| 50 | } |
| 51 | stdout, err := cmd.StdoutPipe() |
| 52 | if err != nil { |
| 53 | t.Fatalf("StdoutPipe: %v", err) |
| 54 | } |
| 55 | stderr := &bytes.Buffer{} |
| 56 | cmd.Stderr = stderr |
| 57 | if err := cmd.Start(); err != nil { |
| 58 | t.Fatalf("start example: %v", err) |
| 59 | } |
| 60 | r := &rawSidecar{t: t, cmd: cmd, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr} |
| 61 | t.Cleanup(func() { |
| 62 | if r.cmd.Process != nil { |
| 63 | _ = r.cmd.Process.Kill() |
| 64 | } |
| 65 | _ = r.wait() |
| 66 | }) |
| 67 | return r |
| 68 | } |
| 69 | |
| 70 | // wait reaps the process exactly once. |
| 71 | func (r *rawSidecar) wait() error { |
| 72 | r.waitOnce.Do(func() { r.waitErr = r.cmd.Wait() }) |
| 73 | return r.waitErr |
| 74 | } |
| 75 | |
| 76 | // waitWithin reaps the process inside the budget. |
| 77 | func (r *rawSidecar) waitWithin(what string, budget time.Duration) error { |
| 78 | done := make(chan error, 1) |
| 79 | go func() { done <- r.wait() }() |
| 80 | select { |
| 81 | case err := <-done: |
| 82 | return err |
| 83 | case <-time.After(budget): |
| 84 | r.t.Fatalf("process did not exit within %s (%s)", budget, what) |
| 85 | return nil |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // send marshals and writes one frame. |
| 90 | func (r *rawSidecar) send(v any) { |
| 91 | r.t.Helper() |
| 92 | raw, err := json.Marshal(v) |
| 93 | if err != nil { |
| 94 | r.t.Fatalf("marshal frame: %v", err) |
| 95 | } |
| 96 | if _, err := r.stdin.Write(append(raw, '\n')); err != nil { |
| 97 | r.t.Fatalf("write frame: %v", err) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // readFrame reads one NDJSON frame. |
| 102 | func (r *rawSidecar) readFrame() (rawFrame, error) { |
| 103 | line, err := r.stdout.ReadBytes('\n') |
| 104 | if err != nil { |
| 105 | return rawFrame{}, err |
| 106 | } |
| 107 | var frame rawFrame |
| 108 | if err := json.Unmarshal(line, &frame); err != nil { |
| 109 | return rawFrame{}, err |
| 110 | } |
| 111 | return frame, nil |
| 112 | } |
| 113 | |
| 114 | // call writes one request and returns its response, failing on any |
| 115 | // interleaved host-bound traffic (none is expected in these scenarios). |
| 116 | func (r *rawSidecar) call(method string, params any) rawFrame { |
| 117 | r.t.Helper() |
| 118 | r.nextID++ |
| 119 | id := r.nextID |
| 120 | r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) |
| 121 | for { |
| 122 | frame, err := r.readFrame() |
| 123 | if err != nil { |
| 124 | r.t.Fatalf("read answer for %s: %v (stderr: %s)", method, err, strings.TrimSpace(r.stderr.String())) |
| 125 | } |
| 126 | if frame.Method != "" { |
| 127 | r.t.Fatalf("extension sent an unexpected host-bound request %q", frame.Method) |
| 128 | } |
| 129 | var gotID int64 |
| 130 | if err := json.Unmarshal(frame.ID, &gotID); err == nil && gotID == id { |
| 131 | return frame |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // handshake runs the initialize exchange and opens the barrier. |
| 137 | func (r *rawSidecar) handshake() { |
| 138 | r.t.Helper() |
| 139 | frame := r.call("extension/initialize", protocol.InitializeParams{ |
| 140 | ProtocolVersion: protocol.ProtocolVersion, |
| 141 | ProtocolID: protocol.ProtocolID, |
| 142 | Session: protocol.SessionContext{SessionID: "raw-sess", WorkspaceRoot: "/ws", Generation: 1}, |
| 143 | Capabilities: protocol.HostCapabilities{ContentRefs: true, UIHost: protocol.UIHostHeadless, ProtocolVersion: protocol.ProtocolVersion}, |
| 144 | }) |
| 145 | if frame.Error != nil { |
| 146 | r.t.Fatalf("initialize answered with an error: %+v", frame.Error) |
| 147 | } |
| 148 | var result protocol.InitializeResult |
| 149 | if err := json.Unmarshal(frame.Result, &result); err != nil { |
| 150 | r.t.Fatalf("decode initialize result: %v", err) |
| 151 | } |
| 152 | r.send(map[string]any{"jsonrpc": "2.0", "method": "extension/initialized", "params": map[string]any{}}) |
| 153 | } |
| 154 | |
| 155 | // TestUnknownMethod sends a request for an unregistered method past the |
| 156 | // handshake: the SDK must answer with the JSON-RPC method-not-found code and |
| 157 | // the frozen unknown_method reason. |
| 158 | func TestUnknownMethod(t *testing.T) { |
| 159 | r := startRawSidecar(t) |
| 160 | r.handshake() |
| 161 | |
| 162 | frame := r.call("extension/bogus", map[string]any{}) |
| 163 | if frame.Error == nil { |
| 164 | t.Fatalf("unknown method answered with result %s", string(frame.Result)) |
| 165 | } |
| 166 | if frame.Error.Code != rpcwire.ErrMethodNotFound { |
| 167 | t.Fatalf("error code = %d, want %d", frame.Error.Code, rpcwire.ErrMethodNotFound) |
| 168 | } |
| 169 | var data protocol.ProtocolErrorData |
| 170 | if err := json.Unmarshal(frame.Error.Data, &data); err != nil { |
| 171 | t.Fatalf("error data does not decode: %v", err) |
| 172 | } |
| 173 | if data.Reason != protocol.ErrUnknownMethod { |
| 174 | t.Fatalf("error reason = %q, want %q", data.Reason, protocol.ErrUnknownMethod) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // TestOversizedFrame sends one NDJSON line beyond the frozen 8 MiB frame |
| 179 | // budget: the SDK must fail the connection and exit non-zero. |
| 180 | func TestOversizedFrame(t *testing.T) { |
| 181 | r := startRawSidecar(t) |
| 182 | line := strings.Repeat("a", protocol.FrameBytes+1024) |
| 183 | go func() { |
| 184 | // The write may fail with EPIPE once the SDK drops the connection; |
| 185 | // either way the connection error is what is being asserted. |
| 186 | _, _ = io.WriteString(r.stdin, line+"\n") |
| 187 | }() |
| 188 | if err := r.waitWithin("oversized frame", 15*time.Second); err == nil { |
| 189 | t.Fatal("process exited 0 after an oversized frame") |
| 190 | } |
| 191 | if !strings.Contains(r.stderr.String(), "frame") { |
| 192 | t.Fatalf("stderr does not mention the frame violation: %q", strings.TrimSpace(r.stderr.String())) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // TestBoundedShutdownExitZero runs the orderly shutdown: the example answers |
| 197 | // accepted:true and the process exits 0 inside the budget. |
| 198 | func TestBoundedShutdownExitZero(t *testing.T) { |
| 199 | r := startRawSidecar(t) |
| 200 | r.handshake() |
| 201 | |
| 202 | frame := r.call("extension/shutdown", protocol.ShutdownParams{TimeoutMillis: 5000}) |
| 203 | if frame.Error != nil { |
| 204 | t.Fatalf("shutdown answered with an error: %+v", frame.Error) |
| 205 | } |
| 206 | var result protocol.ShutdownResult |
| 207 | if err := json.Unmarshal(frame.Result, &result); err != nil { |
| 208 | t.Fatalf("decode shutdown result: %v", err) |
| 209 | } |
| 210 | if !result.Accepted { |
| 211 | t.Fatalf("shutdown not accepted: %+v", result) |
| 212 | } |
| 213 | // The real host closes the sidecar's stdin right after the shutdown |
| 214 | // request (proc.close); the SDK then sees EOF, Serve returns nil, and |
| 215 | // the process exits 0. |
| 216 | if err := r.stdin.Close(); err != nil { |
| 217 | t.Fatalf("close stdin: %v", err) |
| 218 | } |
| 219 | if err := r.waitWithin("shutdown", 10*time.Second); err != nil { |
| 220 | t.Fatalf("process exit = %v, want 0 (stderr: %s)", err, strings.TrimSpace(r.stderr.String())) |
| 221 | } |
| 222 | } |
| 223 |