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
/// Write one 6-hex-framed payload.
19
9
async fn send(stream: &mut TcpStream, payload: &str) {
20
9
    let framed = format!("{:06x}{}", payload.len(), payload);
21
9
    stream.write_all(framed.as_bytes()).await.unwrap();
22
9
    stream.flush().await.unwrap();
23
9
}
24

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

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

            
44
1
    let bin = env!("CARGO_BIN_EXE_nms");
45
1
    let child = tokio::process::Command::new(bin)
46
1
        .arg("--slynk-port")
47
1
        .arg(port.to_string())
48
1
        .stdout(std::process::Stdio::null())
49
1
        .stderr(std::process::Stdio::null())
50
1
        .spawn()
51
1
        .expect("spawn nms --slynk-port");
52

            
53
    // Wait for the listener to come up.
54
1
    for _ in 0..50 {
55
2
        if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
56
1
            break;
57
1
        }
58
1
        tokio::time::sleep(Duration::from_millis(100)).await;
59
    }
60
1
    (child, port)
61
1
}
62

            
63
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
64
1
async fn connect_handshake_and_eval_round_trip() {
65
1
    let (mut child, port) = spawn_server().await;
66
1
    let mut s = TcpStream::connect(("127.0.0.1", port))
67
1
        .await
68
1
        .expect("connect to slynk server");
69

            
70
    // 1. connection-info handshake.
71
1
    send(&mut s, "(:emacs-rex (slynk:connection-info) nil t 1)").await;
72
1
    let reply = recv(&mut s).await;
73
1
    assert!(reply.starts_with("(:return (:ok ("), "got: {reply}");
74
1
    assert!(reply.contains(":lisp-implementation"), "got: {reply}");
75
1
    assert!(reply.contains("nomiscript"), "got: {reply}");
76
1
    assert!(reply.ends_with(" 1)"), "id must echo: {reply}");
77

            
78
    // 2. add-load-paths → ok nil.
79
1
    send(
80
1
        &mut s,
81
1
        "(:emacs-rex (slynk:slynk-add-load-paths '(\"/x/\")) nil t 2)",
82
1
    )
83
1
    .await;
84
1
    assert_eq!(recv(&mut s).await, "(:return (:ok nil) 2)");
85

            
86
    // 3. slynk-require → must include slynk/mrepl.
87
1
    send(
88
1
        &mut s,
89
1
        "(:emacs-rex (slynk:slynk-require '(\"slynk/mrepl\")) nil t 3)",
90
1
    )
91
1
    .await;
92
1
    let req = recv(&mut s).await;
93
1
    assert!(req.contains("slynk/mrepl"), "got: {req}");
94
1
    assert!(req.ends_with(" 3)"), "got: {req}");
95

            
96
    // 4. create-mrepl → (remote thread), then an unsolicited prompt.
97
1
    send(&mut s, "(:emacs-rex (slynk-mrepl:create-mrepl 1) nil t 4)").await;
98
1
    assert_eq!(recv(&mut s).await, "(:return (:ok (1 1)) 4)");
99
1
    assert_eq!(
100
1
        recv(&mut s).await,
101
        "(:channel-send 1 (:prompt \"nomiscript\" \"nomiscript\" 0))"
102
    );
103

            
104
    // 5. eval a pure form via the mREPL channel → write-values "3" + prompt.
105
1
    send(&mut s, "(:emacs-channel-send 1 (:process \"(+ 1 2)\"))").await;
106
1
    assert_eq!(
107
1
        recv(&mut s).await,
108
        "(:channel-send 1 (:write-values ((\"3\" nil nil))))"
109
    );
110
1
    assert_eq!(
111
1
        recv(&mut s).await,
112
        "(:channel-send 1 (:prompt \"nomiscript\" \"nomiscript\" 0))"
113
    );
114

            
115
    // 6. a print form → write-string (captured output) THEN write-values + prompt.
116
1
    send(
117
1
        &mut s,
118
1
        "(:emacs-channel-send 1 (:process \"(print \\\"hi\\\")\"))",
119
1
    )
120
1
    .await;
121
1
    let out = recv(&mut s).await;
122
1
    assert!(
123
1
        out.contains("(:write-string \"hi"),
124
        "expected output, got: {out}"
125
    );
126
1
    let _values = recv(&mut s).await; // write-values
127
1
    let prompt = recv(&mut s).await;
128
1
    assert!(prompt.contains(":prompt"), "got: {prompt}");
129

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

            
134
    // 8. slynk:load-file (M-x sly-load-file): a 2-form file that prints loads,
135
    // persists the defun, and returns a SINGLE :return whose :ok value folds the
136
    // captured output INTO the summary. Crucially there is NO separate top-level
137
    // (:write-string …) frame — SLY has no such event and would crash its
138
    // process filter on one, so the reply must be exactly one frame.
139
1
    let path = std::env::temp_dir().join(format!("nms_proto_load_{}.nms", std::process::id()));
140
1
    std::fs::write(
141
1
        &path,
142
        "(print \"loaded-output\")\n(defun trip (x) (* x 3))\n",
143
    )
144
1
    .unwrap();
145
1
    send(
146
1
        &mut s,
147
1
        &format!(
148
1
            "(:emacs-rex (slynk:load-file \"{}\") nil t 10)",
149
1
            path.display()
150
1
        ),
151
1
    )
152
1
    .await;
153
1
    let load = recv(&mut s).await;
154
1
    std::fs::remove_file(&path).ok();
155
1
    assert!(load.starts_with("(:return (:ok "), "got: {load}");
156
1
    assert!(load.contains("2 forms"), "got: {load}");
157
1
    assert!(
158
1
        load.contains("loaded-output"),
159
        "captured output must ride the :ok value: {load}"
160
    );
161
1
    assert!(load.ends_with(" 10)"), "id must echo: {load}");
162

            
163
    // 9. completion (mREPL TAB): the just-loaded `trip` defun must be offered
164
    // (folded upper-case), proving the symbol table is queried live. Flex shape
165
    // is a list of per-entry tuples; the prefix matches case-insensitively.
166
1
    send(
167
1
        &mut s,
168
1
        "(:emacs-rex (slynk-completion:flex-completions \"tri\" (quote nil)) nil t 11)",
169
1
    )
170
1
    .await;
171
1
    let comp = recv(&mut s).await;
172
1
    assert!(comp.starts_with("(:return (:ok "), "got: {comp}");
173
1
    assert!(
174
1
        comp.contains("\"TRIP\""),
175
        "loaded defun must complete: {comp}"
176
    );
177
1
    assert!(comp.ends_with(" 11)"), "id must echo: {comp}");
178

            
179
1
    child.start_kill().ok();
180
1
}