Files
improvise/crates/improvise-ws-server/tests/socket.rs
T
Edward Langley d00cea2477 fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.

Two halves, per the user's ruling:

* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
  noise on whole numbers is the cheaper error — a currency column with two
  redundant zeroes is legible, a ratio with none is wrong — and a format cannot
  be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
  keeps the view's. `formula.pest` gains
  `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
  reason (a predicate is invisible to the grammar-walking generator) and
  `FORMAT` added to `quote::KEYWORDS`.

`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.

Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.

Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.

The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:52 -07:00

382 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 {
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"
);
// 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;
}));
}