use std::fs; use std::path::PathBuf; use serde_json::{json, Value}; use yrs::types::text::YChange; use yrs::types::Attrs; use yrs::{Any, Doc, Options, Text, Transact}; fn fixture(name: &str) -> (Vec, String, Value) { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("fixtures/data"); let update = fs::read(dir.join(format!("{name}.update.bin"))) .unwrap_or_else(|e| panic!("missing fixture {name}.update.bin: {e}")); let expected: Value = serde_json::from_str( &fs::read_to_string(dir.join(format!("{name}.expected.json"))).unwrap(), ) .unwrap(); let text = expected["text"].as_str().unwrap().to_string(); let delta = expected["delta"].clone(); (update, text, delta) } /// Converts a `yrs::Any` into the equivalent `serde_json::Value`, so an /// attribute map read off the CRDT can be compared directly against the /// `expected.json` fixture written by real JS `yjs`. fn any_to_json(any: &Any) -> Value { match any { Any::Null => Value::Null, Any::Bool(b) => Value::Bool(*b), Any::Number(n) => json!(n), Any::BigInt(n) => json!(n), Any::String(s) => Value::String(s.to_string()), // A Yjs delta's `attributes` map comes from JSON-compatible formatting // marks (bold: true, and similar) — never `undefined` or a binary // buffer. Folding either into `Value::Null` would let a future // fixture with one of these silently compare equal to a bare // `null` attribute value instead of failing loudly on a shape this // test doesn't know how to compare. Any::Undefined => unreachable!( "Any::Undefined in a delta attribute — not representable in the fixture JSON" ), Any::Buffer(_) => { unreachable!("Any::Buffer in a delta attribute — not representable in the fixture JSON") } Any::Array(items) => Value::Array(items.iter().map(any_to_json).collect()), Any::Map(map) => { let mut entries: Vec<(&String, &Any)> = map.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); let mut obj = serde_json::Map::new(); for (k, v) in entries { obj.insert(k.clone(), any_to_json(v)); } Value::Object(obj) } } } /// Converts an `Attrs` map (`HashMap, Any>`) into a `serde_json::Value` /// object, with keys sorted so the comparison isn't flaky on `HashMap` /// iteration order. fn attrs_to_json(attrs: &Attrs) -> Value { let mut entries: Vec<(&std::sync::Arc, &Any)> = attrs.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); let mut obj = serde_json::Map::new(); for (k, v) in entries { obj.insert(k.to_string(), any_to_json(v)); } Value::Object(obj) } /// Produces the same attributed-run shape as Yjs's `toDelta()`, so the /// fixture's `delta` field (which is what actually proves formatting round- /// trips, not just plain text) can be compared directly. fn delta_of(doc: &Doc) -> Value { let text = doc.get_or_insert_text("content"); let txn = doc.transact(); let diffs = text.diff(&txn, YChange::identity); let runs: Vec = diffs .into_iter() .map(|diff| { let insert = diff.insert.to_string(&txn); let mut obj = serde_json::Map::new(); obj.insert("insert".to_string(), Value::String(insert)); if let Some(attrs) = diff.attributes { obj.insert("attributes".to_string(), attrs_to_json(&attrs)); } Value::Object(obj) }) .collect(); Value::Array(runs) } fn check(name: &str) { let (update, expected_text, expected_delta) = fixture(name); // Never yrs::Doc::new() (draws a random client id) or // yrs::Doc::with_client_id() (still draws a random GUID) — both reach // fastrand entropy that this crate's custom getrandom hook refuses to // fabricate. Supply both the client id and the GUID explicitly via // Options::with_guid_and_client_id instead; this is the construction // docs-core itself must use everywhere (see core/src/lib.rs and // clippy.toml, which bans the other two at the source level). let guid: yrs::Uuid = "wire-compat-test-guid".into(); let doc = Doc::with_options(Options::with_guid_and_client_id(guid, 9001)); docs_core::apply_update(&doc, &update).expect("apply failed"); assert_eq!( docs_core::text_content(&doc), expected_text, "fixture {name} text" ); assert_eq!(delta_of(&doc), expected_delta, "fixture {name} delta"); } #[test] fn applies_hello() { check("hello"); } #[test] fn applies_unicode() { check("unicode"); } #[test] fn applies_formatted() { check("formatted"); } #[test] fn applies_concurrent() { check("concurrent"); } // Note: the brief's Step 1 listing calls `docs_core::abi::doc_new()` with no // arguments and destructures `fixture()` into a 2-tuple. Neither matches // what's actually in this file: `doc_new` takes the GUID and client id from // the host (amendment 1 — never drawn from `yrs`, see core/src/lib.rs), and // `fixture()` above returns a 3-tuple (update, text, delta). Adjusted here // to match; the round-trip and error-code behaviour the brief cares about // are unchanged. // // Fix round 1 on the fragment-migration task: this used to apply the // "unicode" fixture (a bare Y.Text on root "content", fixtures/data) and // compare against its plain text. That stopped being what this ABI call // exercises the moment `abi::text_string` was repointed to read // `fragment_text` (root "prosemirror") instead of `text_content` — see // that function's own doc comment. A Y.Text-only fixture round-trips // through `apply_slice` fine (the update decodes and applies regardless // of which root it targets) but reads back as "" through `text_string` // now, which would make this test assert the wrong thing quietly. The // fixture is a fragment one instead (`fixtures/vectors/fragment/simple.bin` // — the same vectors `core/tests/fragment.rs` tests `fragment_text`/ // `fragment_html` against directly), so this still proves what it always // proved: a real Yjs update, applied through the wasm ABI exactly as the // Go host calls it, reads back through that same ABI as the text a // document actually holds. #[test] fn abi_round_trips_a_fixture() { let update = fs::read( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("fixtures/vectors/fragment/simple.bin"), ) .expect("missing fixtures/vectors/fragment/simple.bin"); let expected = "First paragraph.\n\nSecond paragraph."; let handle = docs_core::abi::doc_new("abi-round-trips-a-fixture-guid", 42); assert_ne!(handle, 0, "doc_new returned a null handle"); let rc = docs_core::abi::apply_slice(handle, &update); assert_eq!(rc, 0, "apply returned {rc}"); // text_string returns None for an unknown handle (fix-round-1 finding // 3) and Some(String) for a live one; this handle was just created, so // unwrap is appropriate here. let got = docs_core::abi::text_string(handle).expect("handle was just created"); assert_eq!(got, expected); docs_core::abi::doc_free(handle); } // A single, harmless, non-UB-triggering piece of garbage: `0xff` repeated // is simply not a valid Yjs v1 update header, so `apply_update` rejects it // during ordinary decoding — a clean negative error code, exercised here // as a fast sanity check with no wasm runtime required. #[test] fn abi_rejects_garbage_without_panicking() { let handle = docs_core::abi::doc_new("abi-rejects-garbage-guid", 43); let rc = docs_core::abi::apply_slice(handle, &[0xff, 0xff, 0xff, 0xff]); assert!( rc < 0, "garbage input should return an error code, got {rc}" ); docs_core::abi::doc_free(handle); } // Fix-round-1 finding 5's actual mutation fuzz over the real fixtures // (flip 1-3 bytes, fixed seed, assert containment) lives in // core/tests/wasm_trap_containment.rs, not here. Reason: some mutated // inputs reach real undefined behaviour deep in `yrs`'s wire decode (see // core/src/abi.rs's module doc comment), and under *native* execution // (this file, plain `cargo test`) that UB is not sandboxed the way wasm // linear memory is — it can bring down the whole native test process with // an uncatchable abort (verified empirically while building this fix: a // native-only version of this fuzz hit "thread caused non-unwinding // panic. aborting." within a few thousand random 1-3 byte flips, which is // not something `catch_unwind` can contain). The actual *deployment* // target (wasm32, called through a wasm runtime) does not have this // problem — a wasm trap is contained to the single host call, which is // exactly the property finding 1 and 5 need proven. So the mutation fuzz // runs the real compiled wasm module under `wasmi` instead, where traps // are always safely observable as an `Err` rather than a process crash.