d9359ba93d
- engine: live.join, live.edit, live.poll, live.peers, live.selection, live.info and live.leave over the live session client (at most 8 rooms, no token in any answer, dropping a session with open rooms returns within 2 s); server.file_meta names the last editor of a file - engine: sync.pending lists the local changes the server does not have yet (new, edited, deleted; at most 500, local comparison only) - ui-egui: a "Waiting to sync" queue sheet opened from the sync sheet, and the "You are offline" banner on the compact Notes page - tests: two sessions converge through the real server using live.* commands; stub-server, queue and banner tests Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
126 lines
5.9 KiB
Rust
126 lines
5.9 KiB
Rust
//! The `live.*` commands against the real sync server: two sessions type in the
|
|
//! same room at the same time, their texts meet and the attribution names the
|
|
//! editor of the last write.
|
|
//!
|
|
//! The time is never waited for: the windows of the room are long, the flush is
|
|
//! called by hand and the sessions are polled until they are quiet.
|
|
|
|
mod common;
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use common::Server;
|
|
use notescraft_engine::{Registry, Session, bind_folder, register_core};
|
|
use notescraft_sync_server::{AppState, Store, Timings, flush, now_ms};
|
|
use serde_json::json;
|
|
|
|
/// How many polls without an event mean that the sessions are quiet.
|
|
const QUIET_POLLS: usize = 40;
|
|
/// How long a test waits for the sessions to settle.
|
|
const DEADLINE: Duration = Duration::from_secs(10);
|
|
/// The note both sessions type in.
|
|
const NOTE: &str = "notes/note.md";
|
|
/// The text the note holds before the two sessions type.
|
|
const START: &str = "one\ntwo\n";
|
|
|
|
/// The windows of the room: a short keepalive and long debounces, so the only
|
|
/// write of the test is the flush at the end.
|
|
fn windows() -> Timings {
|
|
Timings { keepalive_ms: 200, idle_ms: 30_000, presence_ttl_ms: 60_000, flush_debounce_ms: 60_000, sweep_ms: 60_000, max_rooms: 8, max_peers: 64 }
|
|
}
|
|
|
|
/// A server over a fresh folder with the windows of the test.
|
|
fn server(name: &str) -> Server {
|
|
let dir = common::temp_dir(name);
|
|
let state = AppState::new(Store::open(&dir.join("sync.db")).expect("db"), dir.join("blobs"), common::ADMIN);
|
|
state.set_live_timings(windows());
|
|
common::start_with_state(state)
|
|
}
|
|
|
|
/// The folder of one engine session, bound to the server.
|
|
fn folder(name: &str, server: &Server, vault: &str, token: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("nc-live-cmd-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("the folder of the session");
|
|
bind_folder(&dir, &server.base, vault, token, "device-1", None).expect("bind the folder");
|
|
dir
|
|
}
|
|
|
|
/// The text of the room, read by joining as a third peer.
|
|
fn room_text(server: &Server, vault: &str, token: &str) -> String {
|
|
let (status, reply) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/live/{NOTE}/join"), Some(token), &[], Some(&json!({"client": "observer", "name": "Observer"})));
|
|
assert_eq!(status, 200, "{reply}");
|
|
reply["text"].as_str().expect("the text of the room").to_owned()
|
|
}
|
|
|
|
/// Polls both rooms until they are quiet and hold the same text.
|
|
fn settle(reg: &Registry<Session>, a: &mut Session, b: &mut Session) -> (String, String) {
|
|
let deadline = Instant::now() + DEADLINE;
|
|
let mut quiet = 0;
|
|
let mut texts = (String::new(), String::new());
|
|
while Instant::now() < deadline {
|
|
let (first, events_a) = poll(reg, a);
|
|
let (second, events_b) = poll(reg, b);
|
|
texts = (first.clone(), second.clone());
|
|
if events_a + events_b == 0 {
|
|
quiet += 1;
|
|
} else {
|
|
quiet = 0;
|
|
}
|
|
if quiet >= QUIET_POLLS && first == second {
|
|
return texts;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
texts
|
|
}
|
|
|
|
/// The text of the room of one session and how many events it heard.
|
|
fn poll(reg: &Registry<Session>, session: &mut Session) -> (String, usize) {
|
|
let answer = reg.run("live.poll", session, &json!({ "path": NOTE })).expect("poll");
|
|
let text = answer["text"].as_str().expect("the text of the room").to_owned();
|
|
let events = answer["events"].as_array().map_or(0, Vec::len);
|
|
(text, events)
|
|
}
|
|
|
|
#[test]
|
|
fn two_sessions_type_in_the_same_room() {
|
|
let server = server("live-cmd-e2e");
|
|
let (vault, token_a, _user_a) = server.owned_vault("Ana");
|
|
let user_b = server.create_user("Ben");
|
|
let (status, token_b) = server.pair_user(&user_b, &vault, "Ben's phone");
|
|
assert_eq!(status, 200, "the second user is paired with the vault");
|
|
let (status, _) = server.put(&vault, &token_a, NOTE, START, 0);
|
|
assert_eq!(status, 201, "the note is in the vault");
|
|
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let mut a = Session::open(&folder("a", &server, &vault, &token_a)).expect("open the first session");
|
|
let mut b = Session::open(&folder("b", &server, &vault, &token_b)).expect("open the second session");
|
|
|
|
let answer = reg.run("live.join", &mut a, &json!({ "path": NOTE })).expect("join the first session");
|
|
assert_eq!(answer["text"], json!(START), "the room hands its text to the first session");
|
|
reg.run("live.join", &mut b, &json!({ "path": NOTE })).expect("join the second session");
|
|
|
|
// Both sessions type at the same time: neither has seen the other yet.
|
|
reg.run("live.edit", &mut a, &json!({ "path": NOTE, "text": "one\ntwo\nA" })).expect("the edit of the first session");
|
|
reg.run("live.edit", &mut b, &json!({ "path": NOTE, "text": "one\ntwo\nB" })).expect("the edit of the second session");
|
|
|
|
let (text_a, text_b) = settle(®, &mut a, &mut b);
|
|
let room = room_text(&server, &vault, &token_a);
|
|
assert_eq!(text_a, room, "the first session holds the text of the room");
|
|
assert_eq!(text_b, room, "the second session holds the text of the room");
|
|
assert!(room.contains('A'), "the marker of the first session was lost: {room}");
|
|
assert!(room.contains('B'), "the marker of the second session was lost: {room}");
|
|
|
|
// The room is written and the attribution names the editor of the write.
|
|
let report = flush(&server.state, &vault, NOTE, now_ms()).expect("the room is written");
|
|
assert!(report.flushed, "the room was written to the vault");
|
|
assert!(!report.conflict, "the room did not conflict with the vault");
|
|
let answer = reg.run("server.file_meta", &mut a, &json!({ "path": NOTE })).expect("the attribution");
|
|
let name = answer["editor_name"].as_str().expect("the name of the last editor");
|
|
assert!(name == "Ana" || name == "Ben", "the last editor is one of the two: {name}");
|
|
}
|