Lines
97.41 %
Functions
100 %
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;
/// How long the spawned server gets to bind its listener before the test fails.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(100);
/// 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");
// stderr is inherited, not discarded: when the server fails to start, its
// own diagnostic is the useful output. Discarding it left the startup
// timeout below reporting only "did not accept a connection".
let mut child = tokio::process::Command::new(bin)
.arg("--slynk-port")
.arg(port.to_string())
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn nms --slynk-port");
// Wait for the listener, then FAIL if it never came up. The previous loop
// ran a fixed 50 x 100ms and fell through either way, so a server that was
// merely slow to bind — a loaded CI node, a coverage-instrumented build —
// produced a connection-refused panic further down, blaming whichever
// protocol step happened to run first. The bound is generous because it is
// a liveness check, not a performance assertion.
let deadline = std::time::Instant::now() + STARTUP_TIMEOUT;
loop {
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
break;
// A server that died is reported as having died, with its status —
// waiting out the full timeout to then blame the socket would hide it.
if let Ok(Some(status)) = child.try_wait() {
panic!("nms --slynk-port {port} exited before binding: {status}");
if std::time::Instant::now() >= deadline {
// Do not leak the process into the rest of the run.
let _ = child.kill().await;
panic!(
"nms --slynk-port {port} did not accept a connection within {STARTUP_TIMEOUT:?}"
);
tokio::time::sleep(STARTUP_POLL_INTERVAL).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();