Files
improvise/crates/improvise-worker-server/tests/post_message.rs
T
Edward Langley bdce1636bb feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS
improvise-9x6: the server half of the standalone static-web deployment
(epic improvise-tm6). `improvise-worker-server` hosts the authoritative
`App`, the command pipeline, the projection layer and VFS persistence behind
`self.onmessage`/`self.postMessage`.

It is small because everything it needs already existed: the frames are
`improvise_protocol::frame`'s (improvise-xks6), the session loop is
`Host` (previous commit), the basis check is `Sessions::apply`/`Checked`
(improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq,
i34). What is genuinely new is three things.

`vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is
unchanged: it is `persistence::load_from` on a `Storage`, the same call the
native binary makes. Seeding is the new problem, and it is a
`Storage::write`, so a fetched demo, an upload and a saved document take the
same parse path and a seed never clobbers an edit. `list` is the document
picker a browser has no OS file manager for.

`worker.rs` — the loop: one port, one client, the `Viewport` handshake
(dropped rather than closed, since there is no connection to close), and the
filter from `Addressed`. Every decision is here and every test points at it.

`bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here
can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and
forwards strings.

Two gaps found and reported rather than papered over. No client on any
transport can ask its host to save — `effect::Save` is `ViewOnly`, so the
wire vocabulary derived from the model effects cannot name it — and a wire
`ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is
raised by a separate `MarkDirty` effect the key path emits. So the worker
persists on its own behalf, keyed off the command variant rather than off a
flag that is never set; `touches_document` carries the argument.

`ClientFrame::Key` is still answered with nothing. A worker CAN call
`reduce_key` — it holds the `App` — but nothing in the projection stream
carries the host's own `ViewState`, so a half-wired arm diverges rather than
under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
demonstrates it against a real `App` and `Sessions` (improvise-bpq2).

22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`,
which drives the worker with nothing but the JSON a `MessagePort` carries.
Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:08:19 -07:00

323 lines
12 KiB
Rust

//! The main thread's traffic, simulated (improvise-9x6 AC 4).
//!
//! The counterpart of `improvise-ws-server`'s `tests/socket.rs` and the wasm
//! client's `tests/browser_round_trip.rs`: a real `Worker` over a real `App`
//! over a real `Storage`, driven with **nothing but strings** — exactly what
//! crosses a `MessagePort` — and asserted on the strings that come back.
//!
//! Two things this can see that a unit test cannot, and one it cannot.
//!
//! It sees the **JSON boundary**: every frame is serialized and deserialized,
//! so a `ClientFrame` the browser could compose but this crate could not parse
//! fails here. And it sees **arrival order**, because the harness folds the
//! frames in the order the worker returned them, which is the order a
//! `postMessage` loop must preserve (gotcha 33).
//!
//! What it cannot see is OPFS. `MemoryFs` stands in, so what is exercised is
//! the `Storage` *contract* rather than the browser's implementation of it —
//! `OpfsFs` owes `storage_contract` and has never been run (gotcha 28,
//! improvise-xf1 deferred). Everything above the trait is real.
use std::path::{Path, PathBuf};
use improvise_command::ui::app::App;
use improvise_command::ui::reduce;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
use improvise_core::view::layout::GridLayout;
use improvise_core::workbook::Workbook;
use improvise_io::persistence;
use improvise_io::storage::MemoryFs;
use improvise_protocol::{
CellId, ClientFrame, Command, GridCache, ModelCommand, Projection, RecordCell, ServerFrame,
WidgetCache,
};
use improvise_worker_server::vfs::{self, Opening};
use improvise_worker_server::worker::Worker;
/// The document a fresh origin is seeded with, written by the real writer.
///
/// Not a hand-rolled `.improv`: the virtual category is spelled `Measure` on
/// disk and `_Measure` in the model, and getting that wrong yields a document
/// that parses into an empty grid — which reads as a projection bug rather
/// than a bad fixture.
fn demo_bytes() -> Vec<u8> {
let mut wb = Workbook::new("Quarterly");
wb.add_category("Region").unwrap();
for item in ["North", "South", "East"] {
wb.add_item("Region", item).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);
for (region, value) in [("North", 10.0), ("South", 20.0), ("East", 30.0)] {
wb.model.data.set(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(value),
);
}
persistence::format_md(&wb).into_bytes()
}
/// The main thread: it holds a port, a grid cache, and no protocol knowledge
/// beyond "post a string, fold what comes back, in order".
struct MainThread {
worker: Worker,
grid: GridCache,
}
impl MainThread {
fn boot(storage: MemoryFs, path: &Path) -> Self {
let workbook = vfs::open(&storage, &Opening::named(path).with_seed(&demo_bytes()))
.expect("the demo seeds into an empty origin");
let mut app = App::with_storage(workbook, Some(path.to_path_buf()), Box::new(storage));
app.rebuild_layout();
Self {
worker: Worker::new(app),
grid: GridCache::default(),
}
}
/// Post one frame and fold everything that comes back. Panics on a frame
/// the worker refuses, which is what a `postMessage` handler should do
/// with an error it cannot route.
fn post(&mut self, frame: &ClientFrame) {
let json = serde_json::to_string(frame).expect("the client serializes its frame");
let replies = self
.worker
.on_message(&json)
.expect("the worker accepts it");
for reply in replies {
let frame: ServerFrame = serde_json::from_str(&reply).expect("a ServerFrame");
self.fold(&frame);
}
}
/// Post a frame and report the raw replies, for the tests whose subject is
/// what the worker *said* rather than what the client ends up holding.
fn ask(&mut self, frame: &ClientFrame) -> Vec<ServerFrame> {
let json = serde_json::to_string(frame).unwrap();
self.worker
.on_message(&json)
.expect("the worker accepts it")
.iter()
.map(|r| serde_json::from_str(r).expect("a ServerFrame"))
.collect()
}
fn fold(&mut self, frame: &ServerFrame) {
match frame {
ServerFrame::Snapshot(snapshot) => {
self.grid = GridCache::default();
for projection in &snapshot.seed {
if let Projection::Grid(update) = projection {
self.grid.apply(update);
}
}
}
ServerFrame::Commands(cmds) => {
for cmd in cmds {
if let Command::Project(Projection::Grid(update)) = cmd {
self.grid.apply(update);
}
}
}
ServerFrame::Rejected(_) => {}
}
}
fn text_at(&self, row: usize, col: usize) -> &str {
&self.grid.cells[row][col].text
}
/// The surrogate the server projected for a cell — the only handle a
/// client has to name one (ADR 002).
fn cell(&self, row: usize, col: usize) -> RecordCell {
match self.grid.cells[row][col].id.as_ref().expect("a cell") {
CellId::Record(rc) => rc.clone(),
CellId::Aggregate(_) => panic!("({row},{col}) is an aggregate"),
}
}
/// A model command composed against the document as it stands. A real
/// client builds its `Basis` from `Projection::Basis`; this shortcut takes
/// it off the `App`, which only a host can do — the staleness case is
/// covered by `a_batch_composed_against_a_moved_document_is_refused`.
fn caught_up(&self, cmd: ModelCommand) -> ClientFrame {
ClientFrame::Command(reduce::caught_up(self.worker.app(), cmd))
}
}
fn origin() -> (MemoryFs, PathBuf) {
(MemoryFs::new(), PathBuf::from("quarterly.improv"))
}
/// The whole acceptance criterion in one run: a document is seeded into an
/// empty origin, opened, projected, edited over the wire, saved, and found
/// again on disk.
#[test]
fn the_worker_loads_projects_edits_and_persists_over_post_message() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(main.grid.row_labels, vec!["North", "South", "East"]);
assert_eq!(main.text_at(0, 0), "10");
let cell = main.cell(2, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(31.0),
}));
assert_eq!(
main.text_at(2, 0),
"31",
"the edit must come back through the projection stream, not be assumed"
);
// Nothing here asks to save. There is no `ModelCommand::Save` for a client
// to send — `effect::Save` is `ViewOnly`, so the wire vocabulary derived
// from the model effects cannot name it (gotcha 38) — so the worker
// persists on its own behalf after a batch dirties the document.
let saved = persistence::load_from(&*main.worker.app().storage, &path)
.expect("the document is in the VFS");
assert_eq!(
value_at(&saved, 2, 0),
Some(CellValue::Number(31.0)),
"the edit never reached storage"
);
}
/// The value at a grid position in a document freshly loaded from the backend.
/// A `CellValue`, not display text, so the assertion does not depend on the
/// view's number format.
fn value_at(wb: &Workbook, row: usize, col: usize) -> Option<CellValue> {
let layout = GridLayout::new(&wb.model, wb.active_view());
let key = layout.cell_key(row, col)?;
wb.model.evaluate_aggregated(&key, &layout.none_cats)
}
/// A second worker over the same backend sees what the first one saved. This
/// is what a browser reload is, and the reason `Storage` is in the picture at
/// all rather than the document living in the page.
#[test]
fn a_reload_over_the_same_origin_reopens_what_was_saved() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
let cell = main.cell(0, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(77.0),
}));
// The "reload": a fresh worker over the backend the first one wrote to,
// offered the same seed it was originally given.
let workbook = vfs::open(
&*main.worker.app().storage,
&Opening::named(&path).with_seed(&demo_bytes()),
)
.expect("reopens");
let mut app = App::with_storage(workbook, Some(path.clone()), Box::new(MemoryFs::new()));
app.rebuild_layout();
let mut reloaded = MainThread {
worker: Worker::new(app),
grid: GridCache::default(),
};
reloaded.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(
reloaded.text_at(0, 0),
"77",
"the reload re-seeded over the user's saved work"
);
}
/// ADR 001 over a `MessagePort`. `ServerFrame::Rejected` is not socket-only,
/// and a worker that never sent one would leave a client's basis claim
/// unanswerable.
#[test]
fn a_batch_composed_against_a_moved_document_is_refused() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
let cell = main.cell(0, 0);
let stale = main.caught_up(ModelCommand::SetCell {
cell: cell.clone(),
value: CellValue::Number(1.0),
});
let winner = main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(2.0),
});
main.post(&winner);
let replies = main.ask(&stale);
assert!(
matches!(replies.as_slice(), [ServerFrame::Rejected(_)]),
"expected a rejection, got {replies:?}"
);
assert_eq!(
main.text_at(0, 0),
"2",
"a refused batch changed the document"
);
}
/// The handshake, enforced by dropping rather than by closing: a worker has no
/// connection to close, and the client has been sent no document to compose
/// against.
#[test]
fn a_command_before_the_first_viewport_is_dropped() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
let replies = main.ask(&ClientFrame::Command(Command::SetSelected {
row: 2,
col: 0,
}));
assert!(
replies.is_empty(),
"answered before the handshake: {replies:?}"
);
assert_eq!(main.worker.app().cursor().selected, (0, 0));
}
/// A resize is a fan-out with nothing dirty: the document did not move, but
/// the window did, so the grid must be re-projected to the new extent.
#[test]
fn a_resize_reprojects_the_grid_although_the_document_is_unchanged() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 1, cols: 4 });
assert_eq!(main.grid.row_labels.len(), 1, "one row fits");
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(
main.grid.row_labels.len(),
3,
"growing the window must deliver the rows it can now show"
);
}
/// `Storage::list` is the document picker a browser has no OS file manager
/// for, and it must see documents the worker created as well as seeded ones.
#[test]
fn the_vfs_offers_every_document_the_origin_holds() {
let (storage, path) = origin();
let main = MainThread::boot(storage, &path);
let fs = &*main.worker.app().storage;
vfs::open(fs, &Opening::named("scratch.improv")).expect("a second, empty document");
assert_eq!(
vfs::documents(fs).unwrap(),
vec![
PathBuf::from("quarterly.improv"),
PathBuf::from("scratch.improv")
],
"a picker must see both the seeded document and the created one"
);
}