Files
improvise/crates/improvise-ws-server/tests/socket.rs
T
Edward Langley 58cd5d3858 improvise-wpxr: SetCell/ClearCell name the view that drew them
An empty record exists only as a layout position, so resolve it against the
layout of the view the command names -- the one grid_in projects for that
session -- rather than the host's own layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 17:55:01 -07:00

390 lines
15 KiB
Rust

//! The one test that uses a real socket — the ticket's AC 5. Its subject is
//! the shell, not the decisions: `context/test-evidence/clients.md`.
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::http::StatusCode;
use improvise_command::ui::app::App;
use improvise_core::model::cell::CellValue;
use improvise_core::view::Axis;
use improvise_core::workbook::Workbook;
use improvise_protocol::{
BasisCache, CellId, Command, GridCache, GridUpdate, ModelCommand, Projection, WidgetCache,
};
use improvise_protocol::{ClientFrame, ServerFrame};
use improvise_ws_server::origin::OriginPolicy;
use improvise_ws_server::registry::{DocumentId, Registry};
/// One document, served as `quarterly`.
fn registry() -> Registry {
let mut registry = Registry::new();
registry
.open(DocumentId::new("quarterly"), app())
.expect("a fresh registry has nothing to collide with");
registry
}
fn app() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
#[test]
fn a_client_on_a_socket_is_seeded_and_gets_its_edit_back() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let local = tokio::task::LocalSet::new();
runtime.block_on(local.run_until(async {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::task::spawn_local(async move {
let _ = improvise_ws_server::transport::serve(
registry(),
listener,
OriginPolicy::default(),
)
.await;
});
// No path: with one document served, `/` reaches it, exactly as it
// did before the server could host several (improvise-j31x).
let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}"))
.await
.expect("the server must accept a websocket connection");
// The handshake: the extent, first, because the snapshot is built at
// it.
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
let ServerFrame::Snapshot(snapshot) = recv(&mut ws).await else {
panic!("the first frame back was not a Snapshot");
};
let mut grid = GridCache::default();
let mut basis = BasisCache::default();
for projection in &snapshot.seed {
match projection {
Projection::Grid(update) => grid.apply(update),
Projection::Basis(update) => basis.apply(update),
_ => {}
}
}
assert!(
basis.current().is_some(),
"the bootstrap must publish a basis, or this client can never \
compose a write at all (ADR 001)"
);
assert_eq!(
grid.row_frame.leaf_labels(),
vec!["North".to_string(), "South".to_string()]
);
assert_eq!(grid.cells[0][0].text, "");
// The client composes the write from what it was *told* — ADR 002. A
// `CellKey` here would be the test knowing what a browser does not.
let CellId::Record(target) = grid.cells[0][0]
.id
.clone()
.expect("the server must name the cell it projected")
else {
panic!("a raw cell must be projected as settable, not as an aggregate");
};
send(
&mut ws,
ClientFrame::command(Command::Model {
// Composed from what the server published and nothing else: a
// self-built basis claims to have seen what was never sent.
basis: basis
.claim([target.record])
.expect("the bootstrap published a basis"),
cmd: ModelCommand::SetCell {
view: "Default".into(),
cell: target,
value: CellValue::Number(12.0),
},
}),
)
.await;
// The grid update may arrive behind the projections for the other
// widgets, so fold frames until the cell lands.
let mut waited = 0;
while grid.cells[0][0].text.is_empty() && waited < 8 {
let ServerFrame::Commands(commands) = recv(&mut ws).await else {
panic!("a second Snapshot arrived for a cell edit");
};
for command in &commands {
if let Command::Project(Projection::Grid(update)) = command {
grid.apply(update);
}
}
waited += 1;
}
assert_eq!(
grid.cells[0][0].text, "12.00",
"the edit never came back down the socket"
);
// The acknowledgement follows everything the frame produced, so a
// client discards its undo knowing what it is discarding (nxah).
assert_eq!(
recv(&mut ws).await,
ServerFrame::Accepted,
"a mutating frame that landed must be acknowledged, and last"
);
// A frame the vocabulary does not cover is skipped, not fatal. The
// resize that follows genuinely reframes a 2x2 fixture to 1x1.
ws.send(Message::Text("{\"NotAFrame\":true}".into()))
.await
.unwrap();
send(&mut ws, ClientFrame::Viewport { rows: 1, cols: 1 }).await;
let ServerFrame::Commands(commands) = recv(&mut ws).await else {
panic!("a resize produced a Snapshot");
};
assert!(
commands
.iter()
.any(|c| matches!(c, Command::Project(Projection::Grid(_)))),
"shrinking the window sent no grid: {commands:?}"
);
assert!(
commands.iter().any(|c| matches!(
c,
Command::Project(Projection::Grid(GridUpdate::ColWidths(_)))
)),
"the reframed grid carried no widths: {commands:?}"
);
}));
}
type Socket =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn send(ws: &mut Socket, msg: ClientFrame) {
ws.send(Message::Text(serde_json::to_string(&msg).unwrap().into()))
.await
.expect("the socket must accept a frame");
}
async fn recv(ws: &mut Socket) -> ServerFrame {
loop {
let message = tokio::time::timeout(std::time::Duration::from_secs(3), ws.next())
.await
.expect("the server sent nothing within three seconds")
.expect("the server closed the connection")
.expect("the socket errored");
if let Message::Text(text) = message {
return serde_json::from_str(&text).expect("the server sent something unparseable");
}
}
}
/// The other thing only a socket can check: the document is named in the URL
/// path, and this covers that the path reaches `Registry::resolve` at all.
#[test]
fn the_url_path_picks_the_document() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let local = tokio::task::LocalSet::new();
runtime.block_on(local.run_until(async {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::task::spawn_local(async move {
let mut registry = Registry::new();
registry
.open(DocumentId::new("alpha"), named_app("Alpha", "Aardvark"))
.unwrap();
registry
.open(DocumentId::new("beta"), named_app("Beta", "Bison"))
.unwrap();
let _ =
improvise_ws_server::transport::serve(registry, listener, OriginPolicy::default())
.await;
});
for (path, row) in [("alpha", "Aardvark"), ("beta", "Bison")] {
let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/{path}"))
.await
.expect("the server must accept a named document");
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
let ServerFrame::Snapshot(snapshot) = recv(&mut ws).await else {
panic!("the first frame back was not a Snapshot");
};
let mut grid = GridCache::default();
for projection in &snapshot.seed {
if let Projection::Grid(update) = projection {
grid.apply(update);
}
}
assert_eq!(
grid.row_frame.leaf_labels(),
vec![row.to_string()],
"/{path} served the wrong document"
);
}
// A name nothing serves: the handshake succeeds — a websocket fact,
// not a document one — and the connection is closed with the reason.
let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/gamma"))
.await
.expect("the handshake itself is not where a document is checked");
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
let closed = tokio::time::timeout(std::time::Duration::from_secs(3), ws.next())
.await
.expect("the server neither answered nor closed")
.expect("the stream ended without a close frame")
.expect("the socket errored");
let Message::Close(Some(frame)) = closed else {
panic!("an unserved document was answered with {closed:?}");
};
assert!(
frame.reason.contains("gamma"),
"the close frame did not say what was wrong: {frame:?}"
);
// And the server is still serving: one refused connection is not the
// registry task falling over.
let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/alpha"))
.await
.expect("the server must still be accepting");
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_)));
}));
}
fn named_app(name: &str, row: &str) -> App {
let mut wb = Workbook::new(name);
wb.add_category("Region").unwrap();
wb.add_item("Region", row).unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// A hostile page an allowed user visits opens a socket from that user's own
/// allowed address. Only `Origin` separates it from the real page.
#[test]
fn a_handshake_from_a_foreign_origin_is_refused() {
with_server(OriginPolicy::default(), |addr| async move {
let Err(refused) =
tokio_tungstenite::connect_async(request(addr, Some("https://evil.example"))).await
else {
panic!("a foreign origin must not reach the document");
};
// The kind, not the wording: a refused upgrade and a dropped socket
// are different outcomes and only one of them is this fix.
let tokio_tungstenite::tungstenite::Error::Http(answer) = refused else {
panic!("the handshake failed for some other reason: {refused}");
};
assert_eq!(answer.status(), StatusCode::FORBIDDEN);
});
}
/// The shipped local recipe: the page is served by `python3 -m http.server`
/// on loopback and opens a socket to the server on another loopback port.
#[test]
fn a_handshake_from_a_loopback_page_is_accepted() {
with_server(OriginPolicy::default(), |addr| async move {
let (mut ws, _) =
tokio_tungstenite::connect_async(request(addr, Some("http://localhost:8080")))
.await
.expect("the local pairing recipe must keep working");
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_)));
});
}
/// Non-browser clients — curl, a native client, this file's other tests —
/// send no `Origin` at all, and absent is decided rather than defaulted.
#[test]
fn a_handshake_with_no_origin_is_accepted() {
with_server(OriginPolicy::default(), |addr| async move {
let (mut ws, _) = tokio_tungstenite::connect_async(request(addr, None))
.await
.expect("a client that sends no Origin is not a browser page");
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_)));
});
}
/// The deployed page reaches the server through a TLS proxy, and `Origin` is
/// the one thing that survives the proxy to say which page it was.
#[test]
fn a_named_origin_reaches_the_document() {
let named = OriginPolicy::new(["https://fiddlerwoaroof.github.io".to_string()]);
with_server(named, |addr| async move {
let (mut ws, _) = tokio_tungstenite::connect_async(request(
addr,
Some("https://fiddlerwoaroof.github.io"),
))
.await
.expect("--allow-origin must actually widen the policy");
send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await;
assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_)));
// Widening for one page does not widen for its neighbours.
assert!(
tokio_tungstenite::connect_async(request(addr, Some("https://evil.example")))
.await
.is_err()
);
});
}
/// A handshake request for `addr`, carrying `origin` when there is one.
fn request(
addr: std::net::SocketAddr,
origin: Option<&str>,
) -> tokio_tungstenite::tungstenite::http::Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = format!("ws://{addr}/")
.into_client_request()
.expect("a loopback address is a valid websocket url");
if let Some(origin) = origin {
request
.headers_mut()
.insert("Origin", origin.parse().expect("a header value"));
}
request
}
/// Serve the one-document fixture on an ephemeral port and run `body`.
fn with_server<F, Fut>(origins: OriginPolicy, body: F)
where
F: FnOnce(std::net::SocketAddr) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let local = tokio::task::LocalSet::new();
runtime.block_on(local.run_until(async move {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::task::spawn_local(async move {
let _ = improvise_ws_server::transport::serve(registry(), listener, origins).await;
});
body(addr).await;
}));
}