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

277 lines
12 KiB
Rust

//! The `live.*` commands against a stub server: the rooms they open, the
//! events they drain, the rooms they refuse and the inputs they survive.
mod common;
mod live_stub;
use std::time::{Duration, Instant};
use common::temp_vault;
use live_stub::Stub;
use notescraft_engine::{Error, Registry, Session, bind_folder, register_core};
use serde_json::{Value, json};
/// The token of the bound vault; it must never reach an answer or an error.
const TOKEN: &str = "secret-token";
/// How long a test waits for the room to answer.
const DEADLINE: Duration = Duration::from_secs(10);
/// The most rooms one session may hold open.
const MAX_ROOMS: usize = 8;
/// A registry with the core commands.
fn registry() -> Registry<Session> {
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
reg
}
/// A vault bound to the stub, with the registry and the session.
fn bound(name: &str, stub: &Stub) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
bind_folder(vault.path(), &stub.base, "vault-1", TOKEN, "device-1", None).expect("bind the vault");
let reg = registry();
let session = Session::open(vault.path()).expect("open the vault");
(reg, session, vault)
}
/// The code of a command error.
fn code(error: &Error) -> String {
match error {
Error::Command { source, .. } => source.code.clone(),
Error::InvalidParams { .. } => "invalid_params".to_owned(),
other => panic!("expected a command error, got {other:?}"),
}
}
/// The message of a command error.
fn message(error: &Error) -> String {
match error {
Error::Command { source, .. } => source.message.clone(),
other => panic!("expected a command error, got {other:?}"),
}
}
/// 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));
}
}
/// Polls the room until the events satisfy `wanted`, or the deadline ends.
fn poll_until(reg: &Registry<Session>, session: &mut Session, wanted: impl Fn(&[Value]) -> bool) -> Value {
let deadline = Instant::now() + DEADLINE;
while Instant::now() < deadline {
let answer = reg.run("live.poll", session, &json!({ "path": "note.md" })).expect("poll");
let events = answer["events"].as_array().cloned().unwrap_or_default();
if wanted(&events) {
return answer;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("the room did not answer in time");
}
/// The events of the last poll.
fn events(answer: &Value) -> Vec<Value> {
answer["events"].as_array().cloned().unwrap_or_default()
}
#[test]
fn joining_a_note_takes_the_text_of_the_room() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-join", &stub);
let answer = reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
assert_eq!(answer["text"], json!("hello"), "the room hands its text to the client");
assert_eq!(answer["already"], json!(false));
assert_eq!(answer["rev"], json!(0));
assert_eq!(answer["peers"], json!([]));
assert_eq!(stub.joins().len(), 1, "the client joined once");
assert!(!answer.to_string().contains(TOKEN), "the token never reaches an answer");
}
#[test]
fn joining_twice_says_already() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-already", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
let again = reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join again");
assert_eq!(again["already"], json!(true), "the second join keeps the room");
assert_eq!(again["text"], json!("hello"));
assert_eq!(stub.joins().len(), 1, "the room was joined once");
}
#[test]
fn an_edit_posts_one_operation() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-edit", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
let answer = reg.run("live.edit", &mut session, &json!({ "path": "note.md", "text": "hello!" })).expect("edit");
assert!(answer["rev"].is_number(), "{answer:?}");
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("the body of the operation");
assert_eq!(body["op"], json!([5, "!"]), "the operation is in the wire format");
assert_eq!(body["base_rev"], json!(0), "the operation is based on the revision of the room");
assert!(ops[0].path.ends_with("/live/note.md/ops"), "{}", ops[0].path);
}
#[test]
fn a_remote_edit_reaches_the_poll() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-poll", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
wait_for_streams(&stub, 1);
stub.push_event("event: op\ndata: {\"rev\":1,\"client\":\"b\",\"op\":[5,\"!\"]}\n\n");
let answer = poll_until(&reg, &mut session, |events| events.iter().any(|event| event["kind"] == json!("remote")));
assert_eq!(answer["text"], json!("hello!"), "the operation of the peer reached the mirror");
let events = events(&answer);
assert!(events.iter().any(|event| event["kind"] == json!("remote")), "{events:?}");
}
#[test]
fn a_closed_room_is_removed() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-closed", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
// The server refuses the operation: the client gives up.
stub.set_ops_status(500);
reg.run("live.edit", &mut session, &json!({ "path": "note.md", "text": "hello!" })).expect("edit");
let answer = poll_until(&reg, &mut session, |events| events.iter().any(|event| event["kind"] == json!("closed")));
let events = events(&answer);
assert!(events.iter().any(|event| event["kind"] == json!("closed")), "{events:?}");
let error = reg.run("live.poll", &mut session, &json!({ "path": "note.md" })).expect_err("the room is gone");
assert_eq!(code(&error), "not_joined");
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
assert_eq!(info["rooms"], json!([]), "the closed room left the session");
}
#[test]
fn a_ninth_room_is_refused() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-limit", &stub);
for index in 0..MAX_ROOMS {
let path = format!("note-{index}.md");
reg.run("live.join", &mut session, &json!({ "path": path })).expect("join");
}
let error = reg.run("live.join", &mut session, &json!({ "path": "note-8.md" })).expect_err("the ninth room");
assert_eq!(code(&error), "limit");
assert!(message(&error).contains("8 rooms"), "{}", message(&error));
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
assert_eq!(info["rooms"].as_array().map(Vec::len), Some(MAX_ROOMS));
}
#[test]
fn an_unbound_vault_cannot_join() {
let stub = Stub::start("hello");
let vault = temp_vault("live-unbound");
let mut session = Session::open(vault.path()).expect("open the vault");
let reg = registry();
let error = reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect_err("join");
assert_eq!(code(&error), "not_bound");
assert!(message(&error).contains("not bound"), "{}", message(&error));
assert!(stub.joins().is_empty(), "no request was made");
}
#[test]
fn a_command_without_a_room_is_refused() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-not-joined", &stub);
let cases: [(&str, Value); 5] = [
("live.edit", json!({ "path": "note.md", "text": "x" })),
("live.poll", json!({ "path": "note.md" })),
("live.peers", json!({ "path": "note.md" })),
("live.selection", json!({ "path": "note.md", "anchor": 0, "head": 0 })),
("live.leave", json!({ "path": "note.md" })),
];
for (id, params) in cases {
let error = reg.run(id, &mut session, &params).expect_err("no room");
assert_eq!(code(&error), "not_joined", "{id}");
}
}
#[test]
fn the_info_and_the_peers_describe_the_room() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-info", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
assert_eq!(info["rooms"][0]["path"], json!("note.md"));
assert_eq!(info["rooms"][0]["rev"], json!(0));
assert_eq!(info["rooms"][0]["peers"], json!([]));
let peers = reg.run("live.peers", &mut session, &json!({ "path": "note.md" })).expect("peers");
assert_eq!(peers["peers"], json!([]));
}
#[test]
fn a_selection_is_accepted_by_the_room() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-selection", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
let answer = reg.run("live.selection", &mut session, &json!({ "path": "note.md", "anchor": 2, "head": 4 })).expect("selection");
assert_eq!(answer["anchor"], json!(2));
assert_eq!(answer["head"], json!(4));
}
#[test]
fn leaving_a_room_closes_it() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-leave", &stub);
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
let answer = reg.run("live.leave", &mut session, &json!({ "path": "note.md" })).expect("leave");
assert_eq!(answer["left"], json!(true));
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
assert_eq!(info["rooms"], json!([]), "the room left the session");
let error = reg.run("live.poll", &mut session, &json!({ "path": "note.md" })).expect_err("the room is gone");
assert_eq!(code(&error), "not_joined");
}
#[test]
fn hostile_params_are_refused() {
let stub = Stub::start("hello");
let (reg, mut session, _vault) = bound("live-hostile", &stub);
let cases = [
("live.join", json!({ "path": "no\u{0}te.md" }), "invalid_value"),
("live.join", json!({ "path": "../escape.md" }), "invalid_value"),
("live.join", json!({ "path": "image.png" }), "invalid_value"),
("live.join", json!({ "path": "" }), "invalid_value"),
("live.join", json!({ "path": 7 }), "invalid_params"),
("live.selection", json!({ "path": "note.md", "anchor": "x", "head": 0 }), "invalid_params"),
];
for (id, params, expected) in cases {
let error = reg.run(id, &mut session, &params).expect_err("hostile params");
assert_eq!(code(&error), expected, "{id} {params}");
}
// Three megabytes of text: a room never holds that much.
let big = "x".repeat(3 * 1024 * 1024);
let error = reg.run("live.edit", &mut session, &json!({ "path": "note.md", "text": big })).expect_err("too much text");
assert_eq!(code(&error), "invalid_value");
}
#[test]
fn dropping_the_session_with_an_open_room_is_quick() {
let stub = Stub::start("hello");
let vault = temp_vault("live-drop");
bind_folder(vault.path(), &stub.base, "vault-1", TOKEN, "device-1", None).expect("bind the vault");
let mut session = Session::open(vault.path()).expect("open the vault");
let reg = registry();
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
let start = Instant::now();
drop(session);
let elapsed = start.elapsed();
assert!(elapsed < Duration::from_secs(2), "the session took {elapsed:?} to drop");
drop(vault);
}