Skip to main content

opencode_ctl/testing/
fake.rs

1//! Scriptable in-memory `Transport`.
2
3use std::collections::{HashMap, VecDeque};
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use serde_json::Value;
8
9use crate::clock::{Clock, TestClock};
10use crate::error::ErrorCode;
11use crate::openapi::{validate_params, validate_session_id, Operation};
12use crate::transport::{Lifecycle, Params, Transport, TransportError, WaitOutcome};
13
14#[derive(Debug, Clone)]
15pub struct RecordedCall {
16    pub op: String,
17    pub params: Params,
18    pub body: Option<Value>,
19    pub at_ms: i64,
20}
21
22struct Script {
23    subset: Vec<(String, String)>,
24    responses: Vec<Result<Value, TransportError>>,
25    next: usize,
26}
27
28struct State {
29    scripts: HashMap<String, Vec<Script>>,
30    waits: VecDeque<(Result<WaitOutcome, TransportError>, u64)>,
31    lifecycle: HashMap<Lifecycle, Result<String, TransportError>>,
32    lifecycle_seq: HashMap<Lifecycle, VecDeque<Result<String, TransportError>>>,
33    cli_version: Result<String, TransportError>,
34    openapi: Option<Value>,
35    calls: Vec<RecordedCall>,
36    wait_calls: Vec<(String, u64, i64)>,
37    lifecycle_calls: Vec<(Lifecycle, i64)>,
38    openapi_fetches: usize,
39}
40
41/// Cloneable handle: clones share one script and one call log.
42#[derive(Clone)]
43pub struct FakeTransport {
44    state: Arc<Mutex<State>>,
45    clock: Option<TestClock>,
46}
47
48fn err(code: ErrorCode, message: impl Into<String>) -> TransportError {
49    TransportError {
50        code,
51        message: message.into(),
52        hint: None,
53        details: None,
54    }
55}
56
57impl Default for FakeTransport {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl FakeTransport {
64    pub fn new() -> Self {
65        let openapi: Value =
66            serde_json::from_str(include_str!("../../tests/fixtures/openapi.json"))
67                .expect("fixture openapi.json is valid JSON");
68        Self {
69            state: Arc::new(Mutex::new(State {
70                scripts: HashMap::new(),
71                waits: VecDeque::new(),
72                lifecycle: HashMap::new(),
73                lifecycle_seq: HashMap::new(),
74                cli_version: Ok("opencode v2.0.32".to_string()),
75                openapi: Some(openapi),
76                calls: Vec::new(),
77                wait_calls: Vec::new(),
78                lifecycle_calls: Vec::new(),
79                openapi_fetches: 0,
80            })),
81            clock: None,
82        }
83    }
84
85    pub fn with_clock(mut self, clock: TestClock) -> Self {
86        self.clock = Some(clock);
87        self
88    }
89
90    fn now(&self) -> i64 {
91        self.clock.as_ref().map_or(0, Clock::now_ms)
92    }
93
94    pub fn on(&self, op_id: &str, responses: Vec<Result<Value, TransportError>>) {
95        self.on_matching(op_id, &[], responses);
96    }
97
98    /// Consumed in order, the last repeats; the script with the largest matching subset wins.
99    pub fn on_matching(
100        &self,
101        op_id: &str,
102        params_subset: &[(&str, &str)],
103        responses: Vec<Result<Value, TransportError>>,
104    ) {
105        let subset = params_subset
106            .iter()
107            .map(|(k, v)| (k.to_string(), v.to_string()))
108            .collect();
109        self.state
110            .lock()
111            .unwrap()
112            .scripts
113            .entry(op_id.to_string())
114            .or_default()
115            .push(Script {
116                subset,
117                responses,
118                next: 0,
119            });
120    }
121
122    pub fn on_wait(&self, script: Vec<(Result<WaitOutcome, TransportError>, u64)>) {
123        self.state.lock().unwrap().waits = script.into();
124    }
125
126    pub fn on_lifecycle(&self, verb: Lifecycle, result: Result<String, TransportError>) {
127        self.state.lock().unwrap().lifecycle.insert(verb, result);
128    }
129
130    /// Answers for `verb` in order, one per call; once used up, `on_lifecycle`'s answer (or the default).
131    pub fn on_lifecycle_seq(&self, verb: Lifecycle, results: Vec<Result<String, TransportError>>) {
132        self.state
133            .lock()
134            .unwrap()
135            .lifecycle_seq
136            .insert(verb, results.into());
137    }
138
139    pub fn on_cli_version(&self, result: Result<String, TransportError>) {
140        self.state.lock().unwrap().cli_version = result;
141    }
142
143    pub fn set_openapi(&self, doc: Option<Value>) {
144        self.state.lock().unwrap().openapi = doc;
145    }
146
147    pub fn calls(&self) -> Vec<RecordedCall> {
148        self.state.lock().unwrap().calls.clone()
149    }
150
151    pub fn wait_calls(&self) -> Vec<(String, u64, i64)> {
152        self.state.lock().unwrap().wait_calls.clone()
153    }
154
155    pub fn lifecycle_calls(&self) -> Vec<(Lifecycle, i64)> {
156        self.state.lock().unwrap().lifecycle_calls.clone()
157    }
158
159    pub fn openapi_fetches(&self) -> usize {
160        self.state.lock().unwrap().openapi_fetches
161    }
162}
163
164impl Transport for FakeTransport {
165    async fn call(
166        &self,
167        op: &Operation,
168        params: &Params,
169        body: Option<&Value>,
170        _deadline: Duration,
171    ) -> Result<Value, TransportError> {
172        // The real transport's own parameter checks (`transport/cli.rs`): an undeclared param is rejected there, so here.
173        let from_tool = |e: crate::error::ToolError| TransportError {
174            code: e.code,
175            message: e.message,
176            hint: e.hint,
177            details: e.details,
178        };
179        validate_params(op, params).map_err(from_tool)?;
180        for (name, value) in params {
181            if name == "sessionID" {
182                validate_session_id(value).map_err(from_tool)?;
183            }
184        }
185        let at_ms = self.now();
186        let mut st = self.state.lock().unwrap();
187        st.calls.push(RecordedCall {
188            op: op.id.clone(),
189            params: params.clone(),
190            body: body.cloned(),
191            at_ms,
192        });
193        let best = st.scripts.get_mut(&op.id).and_then(|list| {
194            list.iter_mut()
195                .filter(|s| s.subset.iter().all(|kv| params.contains(kv)))
196                .max_by_key(|s| s.subset.len())
197        });
198        match best {
199            Some(s) if !s.responses.is_empty() => {
200                let i = s.next.min(s.responses.len() - 1);
201                s.next += 1;
202                s.responses[i].clone()
203            }
204            _ => Err(err(ErrorCode::CliError, format!("unscripted {}", op.id))),
205        }
206    }
207
208    async fn lifecycle(&self, verb: Lifecycle) -> Result<String, TransportError> {
209        let at_ms = self.now();
210        let mut st = self.state.lock().unwrap();
211        st.lifecycle_calls.push((verb, at_ms));
212        if let Some(r) = st
213            .lifecycle_seq
214            .get_mut(&verb)
215            .and_then(VecDeque::pop_front)
216        {
217            return r;
218        }
219        match st.lifecycle.get(&verb) {
220            Some(r) => r.clone(),
221            None => Ok(match verb {
222                Lifecycle::Status | Lifecycle::Start => "http://127.0.0.1:49374".to_string(),
223                Lifecycle::Stop | Lifecycle::Restart => String::new(),
224            }),
225        }
226    }
227
228    async fn wait_idle(
229        &self,
230        session_id: &str,
231        deadline: Duration,
232    ) -> Result<WaitOutcome, TransportError> {
233        let clock = self
234            .clock
235            .clone()
236            .expect("FakeTransport::wait_idle needs with_clock(TestClock)");
237        let at_ms = clock.now_ms();
238        let (result, virtual_ms) = {
239            let mut st = self.state.lock().unwrap();
240            st.wait_calls
241                .push((session_id.to_string(), deadline.as_millis() as u64, at_ms));
242            let item = if st.waits.len() > 1 {
243                st.waits.pop_front()
244            } else {
245                st.waits.front().cloned()
246            };
247            item.unwrap_or((Err(err(ErrorCode::CliError, "unscripted wait_idle")), 0))
248        };
249        let slept = virtual_ms.min(deadline.as_millis() as u64);
250        clock.sleep(Duration::from_millis(slept)).await;
251        result
252    }
253
254    async fn fetch_openapi(&self) -> Result<Value, TransportError> {
255        let mut st = self.state.lock().unwrap();
256        st.openapi_fetches += 1;
257        st.openapi
258            .clone()
259            .ok_or_else(|| err(ErrorCode::ServiceDown, "service not reachable"))
260    }
261
262    async fn cli_version(&self) -> Result<String, TransportError> {
263        self.state.lock().unwrap().cli_version.clone()
264    }
265}