181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
194 lines
8.4 KiB
Rust
194 lines
8.4 KiB
Rust
//! The live session against a stub server: the events of the room are pushed
|
|
//! by the test and the requests of the client are recorded.
|
|
|
|
mod live_stub;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use live_stub::Stub;
|
|
use notescraft_engine::{LiveConfig, LiveEvent, LiveSession};
|
|
use serde_json::{Value, json};
|
|
|
|
/// How long a test waits for the client to catch up.
|
|
const DEADLINE: Duration = Duration::from_secs(10);
|
|
|
|
/// The connection details of a client of the stub.
|
|
fn config(stub: &Stub, client: &str) -> LiveConfig {
|
|
LiveConfig { server: stub.base.clone(), vault_id: "vault".into(), token: "secret-token".into(), client_id: client.into(), name: "Ana".into() }
|
|
}
|
|
|
|
/// Polls the session until the events satisfy `wanted`, or the deadline ends.
|
|
fn wait_for(session: &mut LiveSession, wanted: impl Fn(&[LiveEvent]) -> bool) -> Vec<LiveEvent> {
|
|
let deadline = Instant::now() + DEADLINE;
|
|
let mut all = Vec::new();
|
|
while Instant::now() < deadline {
|
|
let events = session.poll();
|
|
let done = wanted(&events);
|
|
all.extend(events);
|
|
if done {
|
|
return all;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
all
|
|
}
|
|
|
|
/// Waits until the stub opened that many event streams.
|
|
fn wait_for_streams(stub: &Stub, count: usize) {
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while stub.stream_count() < count && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
/// The first event of the list that matches.
|
|
fn find(events: &[LiveEvent], wanted: impl Fn(&LiveEvent) -> bool) -> Option<&LiveEvent> {
|
|
events.iter().find(|event| wanted(event))
|
|
}
|
|
|
|
#[test]
|
|
fn joining_takes_the_text_of_the_room() {
|
|
let stub = Stub::start("hello");
|
|
let (session, text) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
assert_eq!(text, "hello", "the room hands its text to the client");
|
|
assert_eq!(session.text(), "hello");
|
|
assert_eq!(session.rev(), 0);
|
|
let joins = stub.joins();
|
|
assert_eq!(joins.len(), 1, "the client joined once");
|
|
let body: Value = serde_json::from_str(&joins[0].body).expect("join body");
|
|
assert_eq!(body["client"], json!("a"));
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn an_operation_of_a_peer_becomes_a_remote_event() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
wait_for_streams(&stub, 1);
|
|
stub.push_event("event: op\ndata: {\"rev\":1,\"client\":\"b\",\"op\":[5,\"!\"]}\n\n");
|
|
let events = wait_for(&mut session, |events| find(events, |event| matches!(event, LiveEvent::Remote { .. })).is_some());
|
|
let remote = find(&events, |event| matches!(event, LiveEvent::Remote { .. })).expect("a remote event");
|
|
let LiveEvent::Remote { op, text } = remote else {
|
|
panic!("not a remote event");
|
|
};
|
|
assert_eq!(text, "hello!", "the operation of the peer reached the mirror");
|
|
assert_eq!(op.target_len(), 6);
|
|
assert_eq!(session.text(), "hello!");
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn a_local_edit_is_sent_to_the_room() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
session.local_edit("hello!").expect("edit");
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while stub.ops().is_empty() && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
let ops = stub.ops();
|
|
assert_eq!(ops.len(), 1, "the edit went out once");
|
|
let body: Value = serde_json::from_str(&ops[0].body).expect("op body");
|
|
assert_eq!(body["client"], json!("a"));
|
|
assert_eq!(body["base_rev"], json!(0), "the operation is based on the revision of the room");
|
|
assert_eq!(body["op"], json!([5, "!"]), "the operation is in the wire format");
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_edit_waits_for_the_acknowledgement() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
wait_for_streams(&stub, 1);
|
|
session.local_edit("hello!").expect("first edit");
|
|
session.local_edit("hello!!").expect("second edit");
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while stub.ops().is_empty() && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
assert_eq!(stub.ops().len(), 1, "the second edit waits for the confirmation");
|
|
|
|
stub.push_event("event: ack\ndata: {\"rev\":1}\n\n");
|
|
let events = wait_for(&mut session, |events| find(events, |event| matches!(event, LiveEvent::Acked)).is_some());
|
|
assert!(find(&events, |event| matches!(event, LiveEvent::Acked)).is_some(), "the client heard the confirmation");
|
|
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while stub.ops().len() < 2 && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
let ops = stub.ops();
|
|
assert_eq!(ops.len(), 2, "the buffered edit went out after the confirmation");
|
|
let body: Value = serde_json::from_str(&ops[1].body).expect("op body");
|
|
assert_eq!(body["base_rev"], json!(1), "the buffered operation is based on the new revision");
|
|
assert_eq!(body["op"], json!([6, "!"]));
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn the_stream_survives_comments_and_split_data() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
wait_for_streams(&stub, 1);
|
|
// A comment, then an operation whose payload is split over two data lines.
|
|
stub.push_event(": keepalive\n\n");
|
|
stub.push_event("event: op\ndata: {\"rev\":1,\ndata: \"client\":\"b\",\"op\":[5,\"!\"]}\n\n");
|
|
let events = wait_for(&mut session, |events| find(events, |event| matches!(event, LiveEvent::Remote { .. })).is_some());
|
|
assert!(find(&events, |event| matches!(event, LiveEvent::Remote { .. })).is_some(), "the split payload was read");
|
|
assert_eq!(session.text(), "hello!");
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_stream_is_restored() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
wait_for_streams(&stub, 1);
|
|
assert_eq!(stub.stream_count(), 1, "the client reads the stream of the room");
|
|
|
|
stub.drop_streams();
|
|
wait_for_streams(&stub, 2);
|
|
assert!(stub.stream_count() >= 2, "the client opened the stream again");
|
|
assert!(stub.joins().len() >= 2, "the client joined the room again");
|
|
|
|
// The restored stream still carries the events of the room.
|
|
stub.push_event("event: op\ndata: {\"rev\":1,\"client\":\"b\",\"op\":[5,\"!\"]}\n\n");
|
|
let events = wait_for(&mut session, |events| find(events, |event| matches!(event, LiveEvent::Remote { .. })).is_some());
|
|
assert!(find(&events, |event| matches!(event, LiveEvent::Remote { .. })).is_some(), "the restored stream works");
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_operation_joins_the_room_again() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
stub.set_ops_status(409);
|
|
session.local_edit("hello!").expect("edit");
|
|
let events = wait_for(&mut session, |events| find(events, |event| matches!(event, LiveEvent::Resynced { .. })).is_some());
|
|
let resynced = find(&events, |event| matches!(event, LiveEvent::Resynced { .. })).expect("a resync");
|
|
let LiveEvent::Resynced { text, conflict_copy } = resynced else {
|
|
panic!("not a resync");
|
|
};
|
|
assert_eq!(text, "hello!", "our edit is in the merged text");
|
|
assert_eq!(*conflict_copy, None, "the two sides did not overlap");
|
|
assert!(stub.joins().len() >= 2, "the client joined the room again");
|
|
session.leave();
|
|
}
|
|
|
|
#[test]
|
|
fn a_conflicting_room_keeps_our_text() {
|
|
let stub = Stub::start("hello");
|
|
let (mut session, _) = LiveSession::join(config(&stub, "a"), "note.md").expect("join");
|
|
stub.set_room_text("goodbye");
|
|
stub.set_ops_status(409);
|
|
session.local_edit("hello!").expect("edit");
|
|
let events = wait_for(&mut session, |events| find(events, |event| matches!(event, LiveEvent::Resynced { .. })).is_some());
|
|
let resynced = find(&events, |event| matches!(event, LiveEvent::Resynced { .. })).expect("a resync");
|
|
let LiveEvent::Resynced { text, conflict_copy } = resynced else {
|
|
panic!("not a resync");
|
|
};
|
|
assert_eq!(text, "goodbye", "the text of the room wins");
|
|
assert_eq!(*conflict_copy, Some("hello!".to_owned()), "our text is kept as a conflict copy");
|
|
session.leave();
|
|
}
|