Milestone 1: workspace skeleton, wire protocol, socket round-trip
Three-crate workspace per SPECS.md. varde-proto defines the full JSON Lines protocol (requests, envelopes, structured errors, events) with string-typed hashes so the crate carries no iroh dependency. The daemon binds its unix socket, loads config with flags > env > file > defaults precedence, and answers status/list; everything else returns a structured "unimplemented" error. varde-ctl maps subcommands 1:1 onto requests and round-trips status against a real daemon in the tests. Dependencies: serde/serde_json (wire format), tokio (async runtime and unix sockets), tracing/tracing-subscriber (structured logging), toml (config file), anyhow (binary-edge errors), thiserror (reserved for library errors), clap (ctl flag parsing, per spec), tempfile (dev-only, ephemeral test dirs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
//! Milestone 1: the daemon binds its socket and answers the protocol.
|
||||
|
||||
mod support;
|
||||
|
||||
use varde_proto::{ErrorCode, Request, ResponseData, PROTOCOL_VERSION};
|
||||
|
||||
#[test]
|
||||
fn status_round_trips_on_empty_daemon() {
|
||||
let daemon = support::spawn_daemon();
|
||||
let mut client = support::Client::connect(&daemon.socket);
|
||||
|
||||
let reply = client.request(&Request::Status { hash: None });
|
||||
assert!(reply.ok, "status failed: {:?}", reply.error);
|
||||
match reply.data {
|
||||
Some(ResponseData::Status(s)) => {
|
||||
assert_eq!(s.protocol, PROTOCOL_VERSION);
|
||||
assert_eq!(s.pins, 0);
|
||||
assert_eq!(s.store_path, daemon.store.display().to_string());
|
||||
}
|
||||
other => panic!("unexpected status payload: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_empty_on_fresh_daemon() {
|
||||
let daemon = support::spawn_daemon();
|
||||
let mut client = support::Client::connect(&daemon.socket);
|
||||
|
||||
let reply = client.request(&Request::List {});
|
||||
assert!(reply.ok);
|
||||
match reply.data {
|
||||
Some(ResponseData::Pins { pins }) => assert!(pins.is_empty()),
|
||||
other => panic!("unexpected list payload: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_gets_structured_error_and_connection_survives() {
|
||||
let daemon = support::spawn_daemon();
|
||||
let mut client = support::Client::connect(&daemon.socket);
|
||||
|
||||
let reply = client.send_raw("this is not json");
|
||||
assert!(!reply.ok);
|
||||
assert_eq!(reply.error.expect("error body").code, ErrorCode::BadRequest);
|
||||
|
||||
// The same connection keeps working after a bad request.
|
||||
let reply = client.request(&Request::Status { hash: None });
|
||||
assert!(reply.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_concurrent_clients() {
|
||||
let daemon = support::spawn_daemon();
|
||||
let mut a = support::Client::connect(&daemon.socket);
|
||||
let mut b = support::Client::connect(&daemon.socket);
|
||||
|
||||
assert!(a.request(&Request::Status { hash: None }).ok);
|
||||
assert!(b.request(&Request::List {}).ok);
|
||||
assert!(a.request(&Request::List {}).ok);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Shared helpers for integration tests: spawn the real daemon binary on
|
||||
//! ephemeral paths and speak the JSON-lines protocol to it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use varde_proto::{Envelope, Request};
|
||||
|
||||
/// A running daemon process, killed on drop.
|
||||
pub struct DaemonProc {
|
||||
pub child: Child,
|
||||
pub socket: PathBuf,
|
||||
pub store: PathBuf,
|
||||
// Kept alive so the store/socket dirs survive as long as the daemon.
|
||||
_dir: Option<tempfile::TempDir>,
|
||||
}
|
||||
|
||||
impl Drop for DaemonProc {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the compiled varde-daemon on a fresh tempdir.
|
||||
pub fn spawn_daemon() -> DaemonProc {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut proc = spawn_daemon_at(dir.path());
|
||||
proc._dir = Some(dir);
|
||||
proc
|
||||
}
|
||||
|
||||
/// Spawn the daemon against an existing directory (for restart tests).
|
||||
pub fn spawn_daemon_at(dir: &Path) -> DaemonProc {
|
||||
let socket = dir.join("varde.sock");
|
||||
let store = dir.join("store");
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_varde-daemon"))
|
||||
.arg("--user")
|
||||
.arg("--socket")
|
||||
.arg(&socket)
|
||||
.arg("--store")
|
||||
.arg(&store)
|
||||
// Isolate from any config file on the developer's machine.
|
||||
.env("VARDE_CONFIG", dir.join("no-config.toml"))
|
||||
.env("VARDE_DISCOVERY", "false")
|
||||
.spawn()
|
||||
.expect("spawning varde-daemon");
|
||||
// An explicitly named config must exist for the daemon to start.
|
||||
std::fs::write(dir.join("no-config.toml"), "").expect("writing empty config");
|
||||
|
||||
let proc = DaemonProc {
|
||||
child,
|
||||
socket,
|
||||
store,
|
||||
_dir: None,
|
||||
};
|
||||
wait_for_socket(&proc.socket);
|
||||
proc
|
||||
}
|
||||
|
||||
fn wait_for_socket(path: &Path) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if UnixStream::connect(path).is_ok() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
panic!("daemon socket {} never appeared", path.display());
|
||||
}
|
||||
|
||||
/// One connected API client.
|
||||
pub struct Client {
|
||||
reader: BufReader<UnixStream>,
|
||||
writer: UnixStream,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn connect(socket: &Path) -> Client {
|
||||
let stream = UnixStream::connect(socket).expect("connecting to daemon");
|
||||
let reader = BufReader::new(stream.try_clone().expect("cloning stream"));
|
||||
Client {
|
||||
reader,
|
||||
writer: stream,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a request and read the response envelope.
|
||||
pub fn request(&mut self, req: &Request) -> Envelope {
|
||||
let mut line = serde_json::to_string(req).expect("serializing request");
|
||||
line.push('\n');
|
||||
self.writer
|
||||
.write_all(line.as_bytes())
|
||||
.expect("writing request");
|
||||
self.read_envelope()
|
||||
}
|
||||
|
||||
/// Send a raw line (for malformed-input tests).
|
||||
pub fn send_raw(&mut self, raw: &str) -> Envelope {
|
||||
self.writer
|
||||
.write_all(raw.as_bytes())
|
||||
.expect("writing raw line");
|
||||
self.writer.write_all(b"\n").expect("writing newline");
|
||||
self.read_envelope()
|
||||
}
|
||||
|
||||
fn read_envelope(&mut self) -> Envelope {
|
||||
let mut reply = String::new();
|
||||
self.reader.read_line(&mut reply).expect("reading reply");
|
||||
assert!(
|
||||
!reply.is_empty(),
|
||||
"daemon closed connection without replying"
|
||||
);
|
||||
serde_json::from_str(&reply).expect("parsing reply envelope")
|
||||
}
|
||||
|
||||
/// Read one line of the event stream (after Subscribe).
|
||||
// Each test binary compiles its own copy of this module; not every
|
||||
// binary uses every helper.
|
||||
#[allow(dead_code)]
|
||||
pub fn read_event_line(&mut self) -> Option<String> {
|
||||
let mut line = String::new();
|
||||
match self.reader.read_line(&mut line) {
|
||||
Ok(0) => None,
|
||||
Ok(_) => Some(line),
|
||||
Err(e) => panic!("reading event line: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user