diff --git a/crates/etherpad-client/tests/fixtures/wire-vectors.json b/crates/etherpad-client/tests/fixtures/wire-vectors.json new file mode 100644 index 0000000..6b8ff01 --- /dev/null +++ b/crates/etherpad-client/tests/fixtures/wire-vectors.json @@ -0,0 +1,7 @@ +[ + {"name":"plain-insert","initialText":"abc\n","changeset":"Z:4>3=3+3$XYZ","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcXYZ\n"}, + {"name":"plain-delete","initialText":"abcdef\n","changeset":"Z:7<3=1-3$","pool":{"numToAttrib":{},"nextNum":0},"resultText":"aef\n"}, + {"name":"formatted-insert","initialText":"abc\n","changeset":"Z:4>4=3*0+4$bold","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"abcbold\n"}, + {"name":"multiline-insert","initialText":"abc\n","changeset":"Z:4>8=3|2+8$one\ntwo\n","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcone\ntwo\n\n"}, + {"name":"attrib-reuse","initialText":"abc\n","changeset":"Z:4>2*0+1=3*0+1$AB","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"AabcB\n"} +] diff --git a/crates/etherpad-client/tests/smoke.rs b/crates/etherpad-client/tests/smoke.rs new file mode 100644 index 0000000..86b2b99 --- /dev/null +++ b/crates/etherpad-client/tests/smoke.rs @@ -0,0 +1,204 @@ +//! Live wire-compat smoke test (Phase 2 of ether/etherpad#7923). +//! +//! Connects a `PadSession` to a fresh pad on a running Etherpad, sends a +//! changeset that inserts a known marker, then verifies the text round-tripped +//! — preferring the HTTP `getText` API (needs an apikey) and falling back to a +//! fresh `PadSession`'s `initial_text`. +//! +//! Marked `#[ignore]` so plain `cargo test` skips it; the manifest command +//! `cargo test --test smoke -- --ignored` runs it. Modeled on the skip pattern +//! in `integration_etherpad.rs`, but broadened: not only an unreachable base +//! URL but also any failing Etherpad-specific setup step (cookie fetch, WS +//! connect, handshake) prints a skip message and returns rather than failing. +//! This keeps the test safe to run against environments with no Etherpad — or +//! with something else answering on the port. +//! +//! Env contract: +//! ETHERPAD_SMOKE_URL base URL (fallback PAD_ETHERPAD_BASE, then +//! http://localhost:9003) +//! ETHERPAD_SMOKE_APIKEY apikey for the HTTP API getText verification path + +use etherpad_client::Socket; +use etherpad_client::changeset::{Changeset, Op, OpCode}; +use etherpad_client::session::{PadSession, SessionConfig}; +use etherpad_client::socket::TungsteniteSocket; +use std::time::Duration; + +fn smoke_base() -> String { + std::env::var("ETHERPAD_SMOKE_URL") + .or_else(|_| std::env::var("PAD_ETHERPAD_BASE")) + .unwrap_or_else(|_| "http://localhost:9003".to_string()) +} + +async fn reachable(base: &str) -> bool { + let Ok(c) = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + else { + return false; + }; + c.get(base).send().await.is_ok() +} + +/// Canonical insert at position `pos` of `text` into `old_text`. Mirrors the +/// builder used by `integration_roundtrip.rs` (trailing keep is implicit). +fn insert_changeset(old_text: &str, pos: u32, text: &str) -> Changeset { + let old_len = old_text.chars().count() as u32; + let inserted = text.chars().count() as u32; + let mut ops = Vec::new(); + if pos > 0 { + let lines = old_text + .chars() + .take(pos as usize) + .filter(|c| *c == '\n') + .count() as u32; + ops.push(Op { + opcode: OpCode::Keep, + chars: pos, + lines, + attribs: vec![], + }); + } + ops.push(Op { + opcode: OpCode::Insert, + chars: inserted, + lines: text.matches('\n').count() as u32, + attribs: vec![], + }); + Changeset { + old_len, + net_delta: inserted as i64, + ops, + char_bank: text.to_string(), + } +} + +/// Read pad text back via the HTTP API. Returns `Ok(None)` if no apikey is set. +async fn get_text_via_api(base: &str, pad_id: &str) -> Result, String> { + let Ok(apikey) = std::env::var("ETHERPAD_SMOKE_APIKEY") else { + return Ok(None); + }; + if apikey.is_empty() { + return Ok(None); + } + let url = format!("{}/api/1.2.13/getText", base.trim_end_matches('/')); + let c = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + let resp = c + .get(&url) + .query(&[("apikey", apikey.as_str()), ("padID", pad_id)]) + .send() + .await + .map_err(|e| e.to_string())?; + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + // { code, message, data: { text } } + let text = body["data"]["text"].as_str().map(|s| s.to_string()); + Ok(text) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a running Etherpad; run with --ignored"] +async fn smoke_wire_roundtrip() { + let base = smoke_base(); + if !reachable(&base).await { + eprintln!("Etherpad not reachable at {base}, skipping smoke test"); + return; + } + // UUID rather than epoch seconds so concurrent runs never collide. + let pad_id = format!("pad-rust-smoke-{}", uuid::Uuid::now_v7()); + eprintln!("target: {base}/p/{pad_id}"); + + // The HTTP probe above only proves *something* answers at `base`. The + // Etherpad-specific setup (cookie fetch, WS connect, handshake) is also + // treated as a skip condition so a reachable-but-not-Etherpad endpoint + // skips cleanly instead of hard-failing the test. + let cookie = match TungsteniteSocket::fetch_pad_cookie(&base, &pad_id).await { + Ok(c) => c, + Err(e) => { + eprintln!("fetch_pad_cookie failed ({e}); not a usable Etherpad, skipping"); + return; + } + }; + let mut socket = TungsteniteSocket::new(&base, Some(cookie)); + if let Err(e) = socket.connect().await { + eprintln!("ws connect failed ({e}); not a usable Etherpad, skipping"); + return; + } + let mut session = PadSession::new( + Box::new(socket), + SessionConfig { + pad_id: pad_id.clone(), + token: "t.smoke".into(), + protocol_version: 2, + }, + ); + if let Err(e) = session.handshake().await { + eprintln!("handshake failed ({e}); not a usable Etherpad, skipping"); + return; + } + + let initial_text = session.initial_text().to_string(); + let initial_len = initial_text.chars().count() as u32; + let marker = format!("RUST-SMOKE-{}\n", uuid::Uuid::now_v7()); + let cs = insert_changeset(&initial_text, initial_len, &marker); + session.send_changeset(&cs).await.expect("send_changeset"); + + // Pump briefly so the server processes the commit. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(remaining, session.pump_once_event()).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => { + eprintln!("pump error: {e}"); + break; + } + Err(_) => break, + } + } + session.disconnect().await.ok(); + + let needle = marker.trim_end(); + + // Verification path 1: HTTP API getText (preferred, if apikey provided). + match get_text_via_api(&base, &pad_id).await { + Ok(Some(text)) => { + assert!( + text.contains(needle), + "marker {:?} not found in getText response {:?}", + marker, + text + ); + eprintln!("verified via HTTP getText"); + return; + } + Ok(None) => eprintln!("ETHERPAD_SMOKE_APIKEY unset; verifying via fresh session"), + Err(e) => eprintln!("getText failed ({e}); verifying via fresh session"), + } + + // Verification path 2: fresh session reads the pad's persisted text. + let cookie_b = TungsteniteSocket::fetch_pad_cookie(&base, &pad_id) + .await + .expect("fetch_pad_cookie B"); + let mut sock_b = TungsteniteSocket::new(&base, Some(cookie_b)); + sock_b.connect().await.expect("ws connect B"); + let mut sess_b = PadSession::new( + Box::new(sock_b), + SessionConfig { + pad_id: pad_id.clone(), + token: "t.smoke-B".into(), + protocol_version: 2, + }, + ); + sess_b.handshake().await.expect("handshake B"); + assert!( + sess_b.initial_text().contains(needle), + "marker {:?} not found in fresh session initial text {:?}", + marker, + sess_b.initial_text() + ); + eprintln!("verified via fresh session initial_text"); + sess_b.disconnect().await.ok(); +} diff --git a/crates/etherpad-client/tests/vectors.rs b/crates/etherpad-client/tests/vectors.rs new file mode 100644 index 0000000..ec4c5b6 --- /dev/null +++ b/crates/etherpad-client/tests/vectors.rs @@ -0,0 +1,96 @@ +//! Downstream wire-compatibility vectors (Phase 2 of ether/etherpad#7923). +//! +//! Etherpad core ships a canonical wire-format fixture that every client must +//! decode identically. This test loads that fixture and, for each vector, +//! applies `changeset` to `initialText` using the crate's OWN changeset +//! parser + OT apply path (the same code exercised by `changeset_roundtrip.rs` +//! and `ot_apply.rs`), then asserts the result equals `resultText`. +//! +//! Applying a changeset only produces TEXT — attribute pools annotate spans but +//! do not change the resulting characters — so the pool field is loaded for +//! fidelity with core's fixture schema but is not needed to verify the text +//! outcome. +//! +//! The fixture path is overridable via `ETHERPAD_WIRE_VECTORS` so core CI can +//! inject a fresh copy; it defaults to the vendored +//! `tests/fixtures/wire-vectors.json`. + +use etherpad_client::changeset::parser::parse; +use etherpad_client::ot::apply; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +#[derive(Deserialize)] +struct Pool { + #[serde(rename = "numToAttrib", default)] + #[allow(dead_code)] + num_to_attrib: BTreeMap>, + #[serde(rename = "nextNum", default)] + #[allow(dead_code)] + next_num: u32, +} + +#[derive(Deserialize)] +struct Vector { + name: String, + #[serde(rename = "initialText")] + initial_text: String, + changeset: String, + #[allow(dead_code)] + pool: Pool, + #[serde(rename = "resultText")] + result_text: String, +} + +fn vectors_path() -> PathBuf { + match std::env::var("ETHERPAD_WIRE_VECTORS") { + Ok(p) if !p.is_empty() => PathBuf::from(p), + _ => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/wire-vectors.json"), + } +} + +#[test] +fn wire_vectors() { + let path = vectors_path(); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read wire vectors {}: {e}", path.display())); + let vectors: Vec = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("parse wire vectors {}: {e}", path.display())); + + assert!( + !vectors.is_empty(), + "wire vectors file {} is empty", + path.display() + ); + + let mut failures = Vec::new(); + for v in &vectors { + let cs = match parse(&v.changeset) { + Ok(cs) => cs, + Err(e) => { + failures.push(format!("{}: parse failed: {e}", v.name)); + continue; + } + }; + match apply(&cs, &v.initial_text) { + Ok(actual) if actual == v.result_text => { + eprintln!("ok: {}", v.name); + } + Ok(actual) => failures.push(format!( + "{}: apply mismatch\n expected {:?}\n got {:?}", + v.name, v.result_text, actual + )), + Err(e) => failures.push(format!("{}: apply failed: {e}", v.name)), + } + } + + assert!( + failures.is_empty(), + "{} of {} wire vectors failed:\n{}", + failures.len(), + vectors.len(), + failures.join("\n") + ); +}