1
// Skipped under Miri: drives a real loopback TCP listener and runs wasm
2
// via wasmtime; neither sockets nor Cranelift work under Miri.
3
#![cfg(not(miri))]
4

            
5
//! Protocol-level integration test for the SLYNK server: drives a real
6
//! listener over loopback TCP, replaying the captured SLY connect → mREPL →
7
//! eval frame sequence (`doc/editor/slynk-protocol-transcript.org`) and
8
//! asserting the framed replies. No Emacs required.
9
//!
10
//! Uses the nil user (no `--rpc-user`), so pure-language forms like `(+ 1 2)`
11
//! evaluate without a DB; the DB-backed natives aren't exercised here.
12

            
13
use std::time::Duration;
14

            
15
use tokio::io::{AsyncReadExt, AsyncWriteExt};
16
use tokio::net::TcpStream;
17

            
18
/// How long the spawned server gets to bind its listener before the test fails.
19
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
20
const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(100);
21

            
22
/// Write one 6-hex-framed payload.
23
9
async fn send(stream: &mut TcpStream, payload: &str) {
24
9
    let framed = format!("{:06x}{}", payload.len(), payload);
25
9
    stream.write_all(framed.as_bytes()).await.unwrap();
26
9
    stream.flush().await.unwrap();
27
9
}
28

            
29
/// Read one 6-hex-framed payload.
30
13
async fn recv(stream: &mut TcpStream) -> String {
31
13
    let mut hdr = [0u8; 6];
32
13
    stream.read_exact(&mut hdr).await.unwrap();
33
13
    let len = usize::from_str_radix(std::str::from_utf8(&hdr).unwrap(), 16).unwrap();
34
13
    let mut body = vec![0u8; len];
35
13
    stream.read_exact(&mut body).await.unwrap();
36
13
    String::from_utf8(body).unwrap()
37
13
}
38

            
39
/// Spawn `nms --slynk-port <ephemeral>` and return the bound port. We can't
40
/// call the crate's private `slynk::serve` from an integration test, so drive
41
/// the actual binary — the true end-to-end surface.
42
1
async fn spawn_server() -> (tokio::process::Child, u16) {
43
    // Pick a free port by binding then dropping a std listener.
44
1
    let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
45
1
    let port = probe.local_addr().unwrap().port();
46
1
    drop(probe);
47

            
48
1
    let bin = env!("CARGO_BIN_EXE_nms");
49
    // stderr is inherited, not discarded: when the server fails to start, its
50
    // own diagnostic is the useful output. Discarding it left the startup
51
    // timeout below reporting only "did not accept a connection".
52
1
    let mut child = tokio::process::Command::new(bin)
53
1
        .arg("--slynk-port")
54
1
        .arg(port.to_string())
55
1
        .stdout(std::process::Stdio::null())
56
1
        .spawn()
57
1
        .expect("spawn nms --slynk-port");
58

            
59
    // Wait for the listener, then FAIL if it never came up. The previous loop
60
    // ran a fixed 50 x 100ms and fell through either way, so a server that was
61
    // merely slow to bind — a loaded CI node, a coverage-instrumented build —
62
    // produced a connection-refused panic further down, blaming whichever
63
    // protocol step happened to run first. The bound is generous because it is
64
    // a liveness check, not a performance assertion.
65
1
    let deadline = std::time::Instant::now() + STARTUP_TIMEOUT;
66
    loop {
67
4
        if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
68
1
            break;
69
3
        }
70
        // A server that died is reported as having died, with its status —
71
        // waiting out the full timeout to then blame the socket would hide it.
72
3
        if let Ok(Some(status)) = child.try_wait() {
73
            panic!("nms --slynk-port {port} exited before binding: {status}");
74
3
        }
75
3
        if std::time::Instant::now() >= deadline {
76
            // Do not leak the process into the rest of the run.
77
            let _ = child.kill().await;
78
            panic!(
79
                "nms --slynk-port {port} did not accept a connection within {STARTUP_TIMEOUT:?}"
80
            );
81
3
        }
82
3
        tokio::time::sleep(STARTUP_POLL_INTERVAL).await;
83
    }
84
1
    (child, port)
85
1
}
86

            
87
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
88
1
async fn connect_handshake_and_eval_round_trip() {
89
1
    let (mut child, port) = spawn_server().await;
90
1
    let mut s = TcpStream::connect(("127.0.0.1", port))
91
1
        .await
92
1
        .expect("connect to slynk server");
93

            
94
    // 1. connection-info handshake.
95
1
    send(&mut s, "(:emacs-rex (slynk:connection-info) nil t 1)").await;
96
1
    let reply = recv(&mut s).await;
97
1
    assert!(reply.starts_with("(:return (:ok ("), "got: {reply}");
98
1
    assert!(reply.contains(":lisp-implementation"), "got: {reply}");
99
1
    assert!(reply.contains("nomiscript"), "got: {reply}");
100
1
    assert!(reply.ends_with(" 1)"), "id must echo: {reply}");
101

            
102
    // 2. add-load-paths → ok nil.
103
1
    send(
104
1
        &mut s,
105
1
        "(:emacs-rex (slynk:slynk-add-load-paths '(\"/x/\")) nil t 2)",
106
1
    )
107
1
    .await;
108
1
    assert_eq!(recv(&mut s).await, "(:return (:ok nil) 2)");
109

            
110
    // 3. slynk-require → must include slynk/mrepl.
111
1
    send(
112
1
        &mut s,
113
1
        "(:emacs-rex (slynk:slynk-require '(\"slynk/mrepl\")) nil t 3)",
114
1
    )
115
1
    .await;
116
1
    let req = recv(&mut s).await;
117
1
    assert!(req.contains("slynk/mrepl"), "got: {req}");
118
1
    assert!(req.ends_with(" 3)"), "got: {req}");
119

            
120
    // 4. create-mrepl → (remote thread), then an unsolicited prompt.
121
1
    send(&mut s, "(:emacs-rex (slynk-mrepl:create-mrepl 1) nil t 4)").await;
122
1
    assert_eq!(recv(&mut s).await, "(:return (:ok (1 1)) 4)");
123
1
    assert_eq!(
124
1
        recv(&mut s).await,
125
        "(:channel-send 1 (:prompt \"nomiscript\" \"nomiscript\" 0))"
126
    );
127

            
128
    // 5. eval a pure form via the mREPL channel → write-values "3" + prompt.
129
1
    send(&mut s, "(:emacs-channel-send 1 (:process \"(+ 1 2)\"))").await;
130
1
    assert_eq!(
131
1
        recv(&mut s).await,
132
        "(:channel-send 1 (:write-values ((\"3\" nil nil))))"
133
    );
134
1
    assert_eq!(
135
1
        recv(&mut s).await,
136
        "(:channel-send 1 (:prompt \"nomiscript\" \"nomiscript\" 0))"
137
    );
138

            
139
    // 6. a print form → write-string (captured output) THEN write-values + prompt.
140
1
    send(
141
1
        &mut s,
142
1
        "(:emacs-channel-send 1 (:process \"(print \\\"hi\\\")\"))",
143
1
    )
144
1
    .await;
145
1
    let out = recv(&mut s).await;
146
1
    assert!(
147
1
        out.contains("(:write-string \"hi"),
148
        "expected output, got: {out}"
149
    );
150
1
    let _values = recv(&mut s).await; // write-values
151
1
    let prompt = recv(&mut s).await;
152
1
    assert!(prompt.contains(":prompt"), "got: {prompt}");
153

            
154
    // 7. an unknown rex → abort with the same id (SLY tolerates this).
155
1
    send(&mut s, "(:emacs-rex (slynk:autodoc nil) nil t 9)").await;
156
1
    assert_eq!(recv(&mut s).await, "(:return (:abort \"unimplemented\") 9)");
157

            
158
    // 8. slynk:load-file (M-x sly-load-file): a 2-form file that prints loads,
159
    // persists the defun, and returns a SINGLE :return whose :ok value folds the
160
    // captured output INTO the summary. Crucially there is NO separate top-level
161
    // (:write-string …) frame — SLY has no such event and would crash its
162
    // process filter on one, so the reply must be exactly one frame.
163
1
    let path = std::env::temp_dir().join(format!("nms_proto_load_{}.nms", std::process::id()));
164
1
    std::fs::write(
165
1
        &path,
166
        "(print \"loaded-output\")\n(defun trip (x) (* x 3))\n",
167
    )
168
1
    .unwrap();
169
1
    send(
170
1
        &mut s,
171
1
        &format!(
172
1
            "(:emacs-rex (slynk:load-file \"{}\") nil t 10)",
173
1
            path.display()
174
1
        ),
175
1
    )
176
1
    .await;
177
1
    let load = recv(&mut s).await;
178
1
    std::fs::remove_file(&path).ok();
179
1
    assert!(load.starts_with("(:return (:ok "), "got: {load}");
180
1
    assert!(load.contains("2 forms"), "got: {load}");
181
1
    assert!(
182
1
        load.contains("loaded-output"),
183
        "captured output must ride the :ok value: {load}"
184
    );
185
1
    assert!(load.ends_with(" 10)"), "id must echo: {load}");
186

            
187
    // 9. completion (mREPL TAB): the just-loaded `trip` defun must be offered
188
    // (folded upper-case), proving the symbol table is queried live. Flex shape
189
    // is a list of per-entry tuples; the prefix matches case-insensitively.
190
1
    send(
191
1
        &mut s,
192
1
        "(:emacs-rex (slynk-completion:flex-completions \"tri\" (quote nil)) nil t 11)",
193
1
    )
194
1
    .await;
195
1
    let comp = recv(&mut s).await;
196
1
    assert!(comp.starts_with("(:return (:ok "), "got: {comp}");
197
1
    assert!(
198
1
        comp.contains("\"TRIP\""),
199
        "loaded defun must complete: {comp}"
200
    );
201
1
    assert!(comp.ends_with(" 11)"), "id must echo: {comp}");
202

            
203
1
    child.start_kill().ok();
204
1
}