Skip to main content

opencode_ctl/testing/
mock.rs

1//! Mock `opencode` binary logic (Task 12). `tests/bin/mock_opencode.rs` is a shim over [`main`].
2//!
3//! Test infrastructure only: it reads keyed fixtures from `MOCK_OPENCODE_FIXTURES`, keeps
4//! counters and an argv log in `MOCK_OPENCODE_STATE_DIR`, and never touches the real home, the
5//! network or a real `opencode`.
6
7use std::collections::BTreeMap;
8use std::fs::{self, OpenOptions};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13use serde_json::{json, Map, Value};
14use sha2::{Digest, Sha256};
15
16use crate::openapi::{OperationTable, TableSource};
17
18/// The captured real stderr of `opencode api server.info --server http://127.0.0.1:1`
19/// (opencode v2.0.32; the raised error is `server-connection.ts:96`).
20pub const UNREACHABLE_STDERR: &str = r#"Error: Could not reach server at http://127.0.0.1:1
21    at cli.server-connection.resolve (/$bunfs/root/chunk-sjncn5eg.js:2:1149)
22    at cli.server-connection.resolve (definition) (/$bunfs/root/chunk-bsm4erxq.js:2:402)
23    at cli.api (/$bunfs/root/chunk-7wjtqsrz.js:7:21275)
24    at cli.api (definition) (/$bunfs/root/chunk-sjncn5eg.js:2:1109) {
25  [cause]: ClientError: Transport: Unable to connect. Is the computer able to access the url?
26      at <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:1017)
27      at async <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:1265)
28      at processTicksAndRejections (native:7:39) {
29    [cause]: TypeError: Unable to connect. Is the computer able to access the url?
30        at async <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:981)
31        at async <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:1265)
32  }
33}
34"#;
35
36const DEFAULT_URL: &str = "http://127.0.0.1:49374";
37const PIPE_CAP: usize = 262_144;
38const DEFAULT_BIG: usize = 300_000;
39
40/// Percent-encode every byte outside `[A-Za-z0-9_.-]` as uppercase `%XX`.
41pub fn percent_encode(s: &str) -> String {
42    let mut out = String::with_capacity(s.len());
43    for b in s.bytes() {
44        if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-') {
45            out.push(b as char);
46        } else {
47            out.push_str(&format!("%{b:02X}"));
48        }
49    }
50    out
51}
52
53/// `<op>__<k>=<v>&...`: pairs sorted by name, values (not names) percent-encoded.
54/// With no params the key is the bare operation id.
55pub fn fixture_key(op: &str, params: &[(String, String)]) -> String {
56    if params.is_empty() {
57        return op.to_string();
58    }
59    let mut sorted: Vec<&(String, String)> = params.iter().collect();
60    sorted.sort_by(|a, b| a.0.cmp(&b.0));
61    let pairs: Vec<String> = sorted
62        .iter()
63        .map(|(k, v)| format!("{k}={}", percent_encode(v)))
64        .collect();
65    format!("{op}__{}", pairs.join("&"))
66}
67
68/// Pick the fixture file under `<fixtures>/mock/`, most specific first:
69/// `<op>__<params>__<n>`, `<op>__<params>`, `<op>__<n>`, `<op>`. A numbered family
70/// past its last file repeats that last file.
71pub fn select_fixture(
72    fixtures: &Path,
73    op: &str,
74    params: &[(String, String)],
75    n: u32,
76) -> Option<PathBuf> {
77    let dir = fixtures.join("mock");
78    let numbered = |stem: &str| {
79        (1..=n.max(1))
80            .rev()
81            .map(|m| dir.join(format!("{stem}__{m}.json")))
82            .find(|p| p.is_file())
83    };
84    let plain = |stem: &str| Some(dir.join(format!("{stem}.json"))).filter(|p| p.is_file());
85    let with_params = fixture_key(op, params);
86    if with_params != op {
87        if let Some(p) = numbered(&with_params).or_else(|| plain(&with_params)) {
88            return Some(p);
89        }
90    }
91    numbered(op).or_else(|| plain(op))
92}
93
94/// `MOCK_OPENCODE_MODE` = `<key>:<mode>[,...]`.
95pub fn parse_mode(spec: &str) -> Vec<(String, String)> {
96    spec.split(',')
97        .filter_map(|part| {
98            let (k, m) = part.split_once(':')?;
99            (!k.is_empty() && !m.is_empty()).then(|| (k.trim().to_string(), m.trim().to_string()))
100        })
101        .collect()
102}
103
104/// Whether fd 1 is a pipe (the mock truncates `big` output only then).
105#[cfg(unix)]
106pub fn is_stdout_pipe() -> bool {
107    use std::os::fd::AsFd;
108    use std::os::unix::fs::FileTypeExt;
109    std::io::stdout()
110        .as_fd()
111        .try_clone_to_owned()
112        .ok()
113        .and_then(|fd| fs::File::from(fd).metadata().ok())
114        .is_some_and(|m| m.file_type().is_fifo())
115}
116
117#[cfg(windows)]
118pub fn is_stdout_pipe() -> bool {
119    use std::os::windows::io::AsRawHandle;
120    use windows::Win32::Foundation::HANDLE;
121    use windows::Win32::Storage::FileSystem::{GetFileType, FILE_TYPE_PIPE};
122    let handle = HANDLE(std::io::stdout().as_raw_handle());
123    // SAFETY: the handle is the process's own stdout handle, valid for the call.
124    unsafe { GetFileType(handle) == FILE_TYPE_PIPE }
125}
126
127#[cfg(not(any(unix, windows)))]
128pub fn is_stdout_pipe() -> bool {
129    false
130}
131
132/// One argv-log line (no trailing newline): `{argv, env:{<name>:{present,len,sha8}}, stdout_kind}`
133/// for `OPENCODE_PASSWORD`, `OPENCODE_SERVER_PASSWORD` and every `OPENCODE_CTL_*`; never a value.
134pub fn env_presence_line(argv: &[String], env: &[(String, String)], stdout_kind: &str) -> String {
135    let mut seen: BTreeMap<String, Value> = BTreeMap::new();
136    for fixed in ["OPENCODE_PASSWORD", "OPENCODE_SERVER_PASSWORD"] {
137        seen.insert(
138            fixed.to_string(),
139            json!({"present": false, "len": 0, "sha8": null}),
140        );
141    }
142    for (k, v) in env {
143        if k == "OPENCODE_PASSWORD"
144            || k == "OPENCODE_SERVER_PASSWORD"
145            || k.starts_with("OPENCODE_CTL_")
146        {
147            let digest = Sha256::digest(v.as_bytes());
148            let sha8: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
149            seen.insert(
150                k.clone(),
151                json!({"present": true, "len": v.len(), "sha8": sha8}),
152            );
153        }
154    }
155    let env_obj: Map<String, Value> = seen.into_iter().collect();
156    json!({"argv": argv, "env": env_obj, "stdout_kind": stdout_kind}).to_string()
157}
158
159/// The first `required` property of the operation's JSON body that `body` lacks.
160pub fn validate_body(table: &OperationTable, op: &str, body: &Value) -> Option<String> {
161    let operation = table.get(op)?;
162    operation
163        .body_required
164        .iter()
165        .find(|prop| body.get(prop.as_str()).is_none())
166        .cloned()
167}
168
169struct Reply {
170    exit: i32,
171    stdout: String,
172    stderr: String,
173}
174
175impl Reply {
176    fn new(exit: i32, stdout: &str, stderr: &str) -> Self {
177        Self {
178            exit,
179            stdout: stdout.to_string(),
180            stderr: stderr.to_string(),
181        }
182    }
183
184    fn http(status_line: &str, tag: &str, message: &str) -> Self {
185        Self::new(1, &tagged(tag, message), status_line)
186    }
187}
188
189fn tagged(tag: &str, message: &str) -> String {
190    json!({"_tag": tag, "message": message}).to_string()
191}
192
193fn unknown_error_stack(cause: &str) -> String {
194    format!(
195        "UnknownError\n    at cli.api (/$bunfs/root/chunk-7wjtqsrz.js:7:21275)\n    at cli.api (definition) (/$bunfs/root/chunk-sjncn5eg.js:2:1109) {{\n  [cause]: {cause}\n      at <anonymous> (/$bunfs/root/chunk-7wjtqsrz.js:7:21275)\n}}\n"
196    )
197}
198
199fn now_ms(env: &dyn Fn(&str) -> Option<String>) -> i64 {
200    env("MOCK_OPENCODE_NOW_MS")
201        .and_then(|v| v.trim().parse().ok())
202        .unwrap_or_else(|| {
203            std::time::SystemTime::now()
204                .duration_since(std::time::UNIX_EPOCH)
205                .map(|d| d.as_millis() as i64)
206                .unwrap_or(0)
207        })
208}
209
210/// Expand whole-string `"{{now}}"`, `"{{now-N}}"`, `"{{now+N}}"` to numbers.
211fn expand_now(v: &mut Value, now: i64) {
212    match v {
213        Value::String(s) => {
214            if let Some(rest) = s.strip_prefix("{{now").and_then(|r| r.strip_suffix("}}")) {
215                let delta = match rest {
216                    "" => Some(0),
217                    r if r.starts_with('-') || r.starts_with('+') => r[1..]
218                        .parse::<i64>()
219                        .ok()
220                        .map(|n| if r.starts_with('-') { -n } else { n }),
221                    _ => None,
222                };
223                if let Some(d) = delta {
224                    *v = json!(now + d);
225                }
226            }
227        }
228        Value::Array(a) => a.iter_mut().for_each(|x| expand_now(x, now)),
229        Value::Object(o) => o.values_mut().for_each(|x| expand_now(x, now)),
230        _ => {}
231    }
232}
233
234/// Claim the next 1-based sequence number for `key`: the first free `<key>.<n>.seen` marker.
235fn next_seq(state: &Path, key: &str) -> u32 {
236    let mut n = 1;
237    loop {
238        let marker = state.join(format!("{key}.{n}.seen"));
239        match OpenOptions::new()
240            .write(true)
241            .create_new(true)
242            .open(&marker)
243        {
244            Ok(_) => return n,
245            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
246            Err(_) => return n,
247        }
248    }
249}
250
251/// Count one poll of `key` (append one byte; the file length is the count).
252fn count_poll(state: &Path, key: &str) -> u64 {
253    let path = state.join(format!("{key}.polls"));
254    if let Ok(mut f) = OpenOptions::new().append(true).create(true).open(&path) {
255        let _ = f.write_all(b".");
256    }
257    fs::metadata(&path).map(|m| m.len()).unwrap_or(1)
258}
259
260fn reply_from_fixture(path: &Path, now: i64) -> Reply {
261    let text = fs::read_to_string(path).unwrap_or_default();
262    let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
263        // Not JSON (e.g. the bare service URL): stdout verbatim, exit 0.
264        return Reply::new(0, &text, "");
265    };
266    expand_now(&mut value, now);
267    let stream = |v: Option<&Value>| match v {
268        None | Some(Value::Null) => String::new(),
269        Some(Value::String(s)) => s.clone(),
270        Some(other) => other.to_string(),
271    };
272    match value.get("exit").and_then(Value::as_i64) {
273        Some(exit) => Reply {
274            exit: exit as i32,
275            stdout: stream(value.get("stdout")),
276            stderr: stream(value.get("stderr")),
277        },
278        None => Reply::new(0, &value.to_string(), ""),
279    }
280}
281
282/// A non-empty permission or form list row for the polling modes.
283fn pending_row(kind: &str, params: &[(String, String)]) -> Value {
284    let session = params
285        .iter()
286        .find(|(k, _)| k == "sessionID")
287        .map_or("ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", |(_, v)| v.as_str());
288    let row = if kind == "form" {
289        json!({"id": "frm_mock1", "sessionID": session, "title": "mock form",
290               "fields": [{"key": "answer", "type": "string"}]})
291    } else {
292        json!({"id": "per_mock1", "sessionID": session, "action": "edit",
293               "resources": ["/tmp/w/file.txt"]})
294    };
295    json!({"data": [row], "cursor": {"previous": null, "next": null}})
296}
297
298fn big_array(n: usize) -> Vec<u8> {
299    // `[` + padding + `0,` * k + `0` + `]` is exactly n bytes and valid JSON for n >= 3.
300    let n = n.max(3);
301    let pad = (n - 3) % 2;
302    let k = (n - 3 - pad) / 2;
303    let mut v = Vec::with_capacity(n);
304    v.push(b'[');
305    v.extend(std::iter::repeat_n(b' ', pad));
306    for _ in 0..k {
307        v.extend_from_slice(b"0,");
308    }
309    v.extend_from_slice(b"0]");
310    v
311}
312
313fn write_stdout(bytes: &[u8]) {
314    let mut out = std::io::stdout().lock();
315    let _ = out.write_all(bytes);
316    let _ = out.flush();
317}
318
319fn spawn_grandchild(state: &Path) {
320    #[cfg(unix)]
321    let mut cmd = {
322        let mut c = Command::new("sleep");
323        c.arg("300");
324        c
325    };
326    #[cfg(windows)]
327    let mut cmd = {
328        let mut c = Command::new("ping");
329        c.args(["-n", "300", "127.0.0.1"]);
330        c
331    };
332    #[cfg(not(any(unix, windows)))]
333    let mut cmd = Command::new("sleep");
334    cmd.stdin(Stdio::null())
335        .stdout(Stdio::null())
336        .stderr(Stdio::null());
337    if let Ok(child) = cmd.spawn() {
338        let _ = fs::write(state.join("grandchild.pid"), child.id().to_string());
339    }
340}
341
342struct Ctx<'a> {
343    fixtures: &'a Path,
344    state: &'a Path,
345    modes: Vec<(String, String)>,
346    now: i64,
347    after_polls: u64,
348    big_bytes: usize,
349}
350
351enum Outcome {
352    Reply(Reply),
353    Big,
354    Slow,
355}
356
357impl Ctx<'_> {
358    fn mode_of(&self, key: &str) -> Option<&str> {
359        self.modes
360            .iter()
361            .find(|(k, _)| k == key)
362            .map(|(_, m)| m.as_str())
363    }
364
365    fn fixture_reply(&self, key: &str, params: &[(String, String)], n: u32) -> Option<Reply> {
366        select_fixture(self.fixtures, key, params, n).map(|p| reply_from_fixture(&p, self.now))
367    }
368
369    /// The reply of a failure mode, or `None` for modes that shape the normal flow.
370    fn mode_outcome(&self, key: &str, mode: &str, params: &[(String, String)]) -> Option<Outcome> {
371        let r = match mode {
372            "fail" => Reply::http(
373                "HTTP 500 Internal Server Error",
374                "UnknownError",
375                "mock fail",
376            ),
377            "unmapped" => Reply::new(1, "", "Error: something the mapping table does not know"),
378            "garbage" => Reply::new(0, "not json", ""),
379            "silent" => Reply::new(0, "", ""),
380            "stopped" => Reply::new(0, "stopped", ""),
381            "not-found" => Reply::http(
382                "HTTP 404 Not Found",
383                "SessionNotFoundError",
384                "session not found",
385            ),
386            "invalid-request" => {
387                Reply::http("HTTP 400 Bad Request", "InvalidRequestError", "bad request")
388            }
389            "409" => Reply::http("HTTP 409 Conflict", "ConflictError", "busy"),
390            "synthetic-conflict" => Reply::http(
391                "HTTP 409 Conflict",
392                "ConflictError",
393                "Synthetic input ID conflicts with an existing durable record: msg_x",
394            ),
395            "404-vanished" => Reply::http("HTTP 404 Not Found", "PermissionNotFoundError", "gone"),
396            "503" => Reply::http(
397                "HTTP 503 Service Unavailable",
398                "ServiceUnavailableError",
399                "unavailable",
400            ),
401            "unauthorized" => Reply::new(1, "", "Error: Server at http://x rejected the password"),
402            "unknown-op" => Reply::new(
403                1,
404                "",
405                &unknown_error_stack("Error: Operation not found: nope"),
406            ),
407            "unknown-error" => Reply::new(
408                1,
409                "",
410                &unknown_error_stack("Error: Missing path parameter: sessionID"),
411            ),
412            "unreachable" => Reply::new(1, "", UNREACHABLE_STDERR),
413            "slow" => return Some(Outcome::Slow),
414            // Answers normally and leaves a child running, as a CLI call that auto-started the service does.
415            "leave" => {
416                spawn_grandchild(self.state);
417                Reply::new(0, "{}", "")
418            }
419            "big" => return Some(Outcome::Big),
420            "permission" | "form" => {
421                // Empty until MOCK_OPENCODE_AFTER_POLLS calls have been served.
422                if count_poll(self.state, key) > self.after_polls {
423                    Reply::new(0, &pending_row(mode, params).to_string(), "")
424                } else {
425                    return None;
426                }
427            }
428            other => Reply::new(2, "", &format!("mock-opencode: unknown mode {other}")),
429        };
430        Some(Outcome::Reply(r))
431    }
432
433    fn api(&self, op: &str, params: &[(String, String)], body: Option<&str>) -> Outcome {
434        let n = next_seq(self.state, op);
435        if let Some(mode) = self.mode_of(op) {
436            if let Some(o) = self.mode_outcome(op, mode, params) {
437                return o;
438            }
439        }
440        let get = |name: &str| params.iter().any(|(k, _)| k == name);
441        let bad =
442            |tag: &str, msg: &str| Outcome::Reply(Reply::new(1, &tagged(tag, msg), "HTTP 400"));
443        if op == "session.message.list" {
444            if params.iter().any(|(k, v)| k == "type" && v == "idle") {
445                return bad("InvalidRequestError", "invalid type");
446            }
447            if get("cursor") && get("order") {
448                return bad("InvalidCursorError", "order not allowed with cursor");
449            }
450        }
451        if let Some(body) = body {
452            let table = fs::read_to_string(self.fixtures.join("openapi.json"))
453                .ok()
454                .and_then(|t| serde_json::from_str::<Value>(&t).ok())
455                .and_then(|d| OperationTable::from_openapi(&d, TableSource::Live).ok());
456            match serde_json::from_str::<Value>(body) {
457                Err(_) => return bad("InvalidRequestError", "invalid json body"),
458                Ok(v) => {
459                    if let Some(prop) = table.and_then(|t| validate_body(&t, op, &v)) {
460                        return bad("InvalidRequestError", &format!("missing {prop}"));
461                    }
462                }
463            }
464        }
465        match self.fixture_reply(op, params, n) {
466            Some(r) => Outcome::Reply(r),
467            None => Outcome::Reply(Reply::new(
468                1,
469                &json!({"_tag": "MockMissingFixture", "key": fixture_key(op, params)}).to_string(),
470                "",
471            )),
472        }
473    }
474
475    fn service(&self, verb: &str) -> Outcome {
476        let key = format!("service.{verb}");
477        let n = next_seq(self.state, &key);
478        if let Some(mode) = self.mode_of(&key) {
479            // `stopped` is meaningful for `service.status` only.
480            if mode != "stopped" || verb == "status" {
481                if let Some(o) = self.mode_outcome(&key, mode, &[]) {
482                    return o;
483                }
484            }
485        }
486        if let Some(r) = self.fixture_reply(&key, &[], n) {
487            return Outcome::Reply(r);
488        }
489        let out = if verb == "status" { DEFAULT_URL } else { "" };
490        Outcome::Reply(Reply::new(0, out, ""))
491    }
492}
493
494fn finish(ctx: &Ctx, outcome: Outcome) -> i32 {
495    match outcome {
496        Outcome::Reply(r) => {
497            write_stdout(r.stdout.as_bytes());
498            let _ = std::io::stderr().write_all(r.stderr.as_bytes());
499            r.exit
500        }
501        Outcome::Big => {
502            let mut bytes = big_array(ctx.big_bytes);
503            if is_stdout_pipe() {
504                bytes.truncate(PIPE_CAP);
505            }
506            write_stdout(&bytes);
507            0
508        }
509        Outcome::Slow => {
510            spawn_grandchild(ctx.state);
511            loop {
512                std::thread::sleep(std::time::Duration::from_secs(3600));
513            }
514        }
515    }
516}
517
518/// Parse `api` arguments after the subcommand: the first positional(s), `--param k=v`,
519/// `-d <json>`, `--server <url>`.
520fn parse_api(args: &[String]) -> (Vec<String>, Vec<(String, String)>, Option<String>) {
521    let (mut pos, mut params, mut body) = (Vec::new(), Vec::new(), None);
522    let mut it = args.iter();
523    while let Some(a) = it.next() {
524        match a.as_str() {
525            "--param" => {
526                if let Some((k, v)) = it.next().and_then(|kv| kv.split_once('=')) {
527                    params.push((k.to_string(), v.to_string()));
528                }
529            }
530            "-d" | "--data" => body = it.next().cloned(),
531            "--server" => {
532                it.next();
533            }
534            _ => pos.push(a.clone()),
535        }
536    }
537    (pos, params, body)
538}
539
540fn append_log(state: &Path, line: &str) {
541    let mut text = line.to_string();
542    text.push('\n');
543    if let Ok(mut f) = OpenOptions::new()
544        .append(true)
545        .create(true)
546        .open(state.join("argv.jsonl"))
547    {
548        // ONE write_all so concurrent mock processes never interleave.
549        let _ = f.write_all(text.as_bytes());
550    }
551}
552
553pub fn main() {
554    let env = |k: &str| std::env::var(k).ok();
555    let (Some(state), Some(fixtures)) = (
556        env("MOCK_OPENCODE_STATE_DIR"),
557        env("MOCK_OPENCODE_FIXTURES"),
558    ) else {
559        eprintln!("mock-opencode: MOCK_OPENCODE_STATE_DIR and MOCK_OPENCODE_FIXTURES are required");
560        std::process::exit(2);
561    };
562    let (state, fixtures) = (PathBuf::from(state), PathBuf::from(fixtures));
563    let _ = fs::create_dir_all(&state);
564    let argv: Vec<String> = std::env::args().skip(1).collect();
565    let kind = if is_stdout_pipe() { "pipe" } else { "file" };
566    let vars: Vec<(String, String)> = std::env::vars().collect();
567    append_log(&state, &env_presence_line(&argv, &vars, kind));
568
569    let ctx = Ctx {
570        fixtures: &fixtures,
571        state: &state,
572        modes: env("MOCK_OPENCODE_MODE")
573            .map(|m| parse_mode(&m))
574            .unwrap_or_default(),
575        now: now_ms(&env),
576        after_polls: env("MOCK_OPENCODE_AFTER_POLLS")
577            .and_then(|v| v.trim().parse().ok())
578            .unwrap_or(0),
579        big_bytes: env("MOCK_OPENCODE_BIG_BYTES")
580            .and_then(|v| v.trim().parse().ok())
581            .unwrap_or(DEFAULT_BIG),
582    };
583    let outcome = match argv.first().map(String::as_str) {
584        Some("--version") => Outcome::Reply(Reply::new(0, "opencode v2.0.32\n", "")),
585        Some("service") => match argv.get(1).map(String::as_str) {
586            Some(v @ ("start" | "status" | "stop" | "restart" | "get" | "set" | "unset")) => {
587                ctx.service(v)
588            }
589            _ => Outcome::Reply(Reply::new(2, "", "mock-opencode: unknown service verb")),
590        },
591        Some("api") => {
592            let (pos, params, body) = parse_api(&argv[1..]);
593            match pos.as_slice() {
594                [m, path] if m.eq_ignore_ascii_case("GET") && path == "/openapi.json" => {
595                    match fs::read_to_string(fixtures.join("openapi.json")) {
596                        Ok(t) => Outcome::Reply(Reply::new(0, &t, "")),
597                        Err(e) => Outcome::Reply(Reply::new(1, "", &format!("mock-opencode: {e}"))),
598                    }
599                }
600                [op] => ctx.api(op, &params, body.as_deref()),
601                _ => Outcome::Reply(Reply::new(
602                    2,
603                    "",
604                    "Expected an operation name or an HTTP method and path",
605                )),
606            }
607        }
608        _ => Outcome::Reply(Reply::new(2, "", "mock-opencode: unsupported command")),
609    };
610    std::process::exit(finish(&ctx, outcome));
611}