Lines
100 %
Functions
Branches
// Skipped under Miri: drives a real loopback TCP listener and runs wasm
// via wasmtime; neither sockets nor Cranelift work under Miri.
#![cfg(not(miri))]
//! Protocol-level integration test for the SLYNK server: drives a real
//! listener over loopback TCP, replaying the captured SLY connect → mREPL →
//! eval frame sequence (`doc/editor/slynk-protocol-transcript.org`) and
//! asserting the framed replies. No Emacs required.
//!
//! Uses the nil user (no `--rpc-user`), so pure-language forms like `(+ 1 2)`
//! evaluate without a DB; the DB-backed natives aren't exercised here.
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
/// Write one 6-hex-framed payload.
async fn send(stream: &mut TcpStream, payload: &str) {
let framed = format!("{:06x}{}", payload.len(), payload);
stream.write_all(framed.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
}
/// Read one 6-hex-framed payload.
async fn recv(stream: &mut TcpStream) -> String {
let mut hdr = [0u8; 6];
stream.read_exact(&mut hdr).await.unwrap();
let len = usize::from_str_radix(std::str::from_utf8(&hdr).unwrap(), 16).unwrap();
let mut body = vec![0u8; len];
stream.read_exact(&mut body).await.unwrap();
String::from_utf8(body).unwrap()
/// Spawn `nms --slynk-port <ephemeral>` and return the bound port. We can't
/// call the crate's private `slynk::serve` from an integration test, so drive
/// the actual binary — the true end-to-end surface.
async fn spawn_server() -> (tokio::process::Child, u16) {
// Pick a free port by binding then dropping a std listener.
let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let bin = env!("CARGO_BIN_EXE_nms");
let child = tokio::process::Command::new(bin)
.arg("--slynk-port")
.arg(port.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nms --slynk-port");
// Wait for the listener to come up.
for _ in 0..50 {
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
break;
tokio::time::sleep(Duration::from_millis(100)).await;
(child, port)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn connect_handshake_and_eval_round_trip() {
let (mut child, port) = spawn_server().await;
let mut s = TcpStream::connect(("127.0.0.1", port))
.await
.expect("connect to slynk server");
// 1. connection-info handshake.
send(&mut s, "(:emacs-rex (slynk:connection-info) nil t 1)").await;
let reply = recv(&mut s).await;
assert!(reply.starts_with("(:return (:ok ("), "got: {reply}");
assert!(reply.contains(":lisp-implementation"), "got: {reply}");
assert!(reply.contains("nomiscript"), "got: {reply}");
assert!(reply.ends_with(" 1)"), "id must echo: {reply}");
// 2. add-load-paths → ok nil.
send(
&mut s,
"(:emacs-rex (slynk:slynk-add-load-paths '(\"/x/\")) nil t 2)",
)
.await;
assert_eq!(recv(&mut s).await, "(:return (:ok nil) 2)");
// 3. slynk-require → must include slynk/mrepl.
"(:emacs-rex (slynk:slynk-require '(\"slynk/mrepl\")) nil t 3)",
let req = recv(&mut s).await;
assert!(req.contains("slynk/mrepl"), "got: {req}");
assert!(req.ends_with(" 3)"), "got: {req}");
// 4. create-mrepl → (remote thread), then an unsolicited prompt.
send(&mut s, "(:emacs-rex (slynk-mrepl:create-mrepl 1) nil t 4)").await;
assert_eq!(recv(&mut s).await, "(:return (:ok (1 1)) 4)");
assert_eq!(
recv(&mut s).await,
"(:channel-send 1 (:prompt \"nomiscript\" \"nomiscript\" 0))"
);
// 5. eval a pure form via the mREPL channel → write-values "3" + prompt.
send(&mut s, "(:emacs-channel-send 1 (:process \"(+ 1 2)\"))").await;
"(:channel-send 1 (:write-values ((\"3\" nil nil))))"
// 6. a print form → write-string (captured output) THEN write-values + prompt.
"(:emacs-channel-send 1 (:process \"(print \\\"hi\\\")\"))",
let out = recv(&mut s).await;
assert!(
out.contains("(:write-string \"hi"),
"expected output, got: {out}"
let _values = recv(&mut s).await; // write-values
let prompt = recv(&mut s).await;
assert!(prompt.contains(":prompt"), "got: {prompt}");
// 7. an unknown rex → abort with the same id (SLY tolerates this).
send(&mut s, "(:emacs-rex (slynk:autodoc nil) nil t 9)").await;
assert_eq!(recv(&mut s).await, "(:return (:abort \"unimplemented\") 9)");
// 8. slynk:load-file (M-x sly-load-file): a 2-form file that prints loads,
// persists the defun, and returns a SINGLE :return whose :ok value folds the
// captured output INTO the summary. Crucially there is NO separate top-level
// (:write-string …) frame — SLY has no such event and would crash its
// process filter on one, so the reply must be exactly one frame.
let path = std::env::temp_dir().join(format!("nms_proto_load_{}.nms", std::process::id()));
std::fs::write(
&path,
"(print \"loaded-output\")\n(defun trip (x) (* x 3))\n",
.unwrap();
&format!(
"(:emacs-rex (slynk:load-file \"{}\") nil t 10)",
path.display()
),
let load = recv(&mut s).await;
std::fs::remove_file(&path).ok();
assert!(load.starts_with("(:return (:ok "), "got: {load}");
assert!(load.contains("2 forms"), "got: {load}");
load.contains("loaded-output"),
"captured output must ride the :ok value: {load}"
assert!(load.ends_with(" 10)"), "id must echo: {load}");
// 9. completion (mREPL TAB): the just-loaded `trip` defun must be offered
// (folded upper-case), proving the symbol table is queried live. Flex shape
// is a list of per-entry tuples; the prefix matches case-insensitively.
"(:emacs-rex (slynk-completion:flex-completions \"tri\" (quote nil)) nil t 11)",
let comp = recv(&mut s).await;
assert!(comp.starts_with("(:return (:ok "), "got: {comp}");
comp.contains("\"TRIP\""),
"loaded defun must complete: {comp}"
assert!(comp.ends_with(" 11)"), "id must echo: {comp}");
child.start_kill().ok();