Files
Marcos Paulo d9359ba93d
ci / ci (windows-latest) (push) Has been cancelled
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
feat: live.* engine commands, last-edit lookup, waiting-to-sync queue and offline banner
- 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>
2026-10-20 13:00:13 -03:00

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(&reg, &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}");
}