oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1use std::fs;
2use std::path::PathBuf;
3
4use serde_json::{json, Value};
5use yrs::types::text::YChange;
6use yrs::types::Attrs;
7use yrs::{Any, Doc, Options, Text, Transact};
8
9fn fixture(name: &str) -> (Vec<u8>, String, Value) {
10 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
11 .parent()
12 .unwrap()
13 .join("fixtures/data");
14 let update = fs::read(dir.join(format!("{name}.update.bin")))
15 .unwrap_or_else(|e| panic!("missing fixture {name}.update.bin: {e}"));
16 let expected: Value = serde_json::from_str(
17 &fs::read_to_string(dir.join(format!("{name}.expected.json"))).unwrap(),
18 )
19 .unwrap();
20 let text = expected["text"].as_str().unwrap().to_string();
21 let delta = expected["delta"].clone();
22 (update, text, delta)
23}
24
25/// Converts a `yrs::Any` into the equivalent `serde_json::Value`, so an
26/// attribute map read off the CRDT can be compared directly against the
27/// `expected.json` fixture written by real JS `yjs`.
28fn any_to_json(any: &Any) -> Value {
29 match any {
30 Any::Null => Value::Null,
31 Any::Bool(b) => Value::Bool(*b),
32 Any::Number(n) => json!(n),
33 Any::BigInt(n) => json!(n),
34 Any::String(s) => Value::String(s.to_string()),
35 // A Yjs delta's `attributes` map comes from JSON-compatible formatting
36 // marks (bold: true, and similar) — never `undefined` or a binary
37 // buffer. Folding either into `Value::Null` would let a future
38 // fixture with one of these silently compare equal to a bare
39 // `null` attribute value instead of failing loudly on a shape this
40 // test doesn't know how to compare.
41 Any::Undefined => unreachable!(
42 "Any::Undefined in a delta attribute — not representable in the fixture JSON"
43 ),
44 Any::Buffer(_) => {
45 unreachable!("Any::Buffer in a delta attribute — not representable in the fixture JSON")
46 }
47 Any::Array(items) => Value::Array(items.iter().map(any_to_json).collect()),
48 Any::Map(map) => {
49 let mut entries: Vec<(&String, &Any)> = map.iter().collect();
50 entries.sort_by(|a, b| a.0.cmp(b.0));
51 let mut obj = serde_json::Map::new();
52 for (k, v) in entries {
53 obj.insert(k.clone(), any_to_json(v));
54 }
55 Value::Object(obj)
56 }
57 }
58}
59
60/// Converts an `Attrs` map (`HashMap<Arc<str>, Any>`) into a `serde_json::Value`
61/// object, with keys sorted so the comparison isn't flaky on `HashMap`
62/// iteration order.
63fn attrs_to_json(attrs: &Attrs) -> Value {
64 let mut entries: Vec<(&std::sync::Arc<str>, &Any)> = attrs.iter().collect();
65 entries.sort_by(|a, b| a.0.cmp(b.0));
66 let mut obj = serde_json::Map::new();
67 for (k, v) in entries {
68 obj.insert(k.to_string(), any_to_json(v));
69 }
70 Value::Object(obj)
71}
72
73/// Produces the same attributed-run shape as Yjs's `toDelta()`, so the
74/// fixture's `delta` field (which is what actually proves formatting round-
75/// trips, not just plain text) can be compared directly.
76fn delta_of(doc: &Doc) -> Value {
77 let text = doc.get_or_insert_text("content");
78 let txn = doc.transact();
79 let diffs = text.diff(&txn, YChange::identity);
80 let runs: Vec<Value> = diffs
81 .into_iter()
82 .map(|diff| {
83 let insert = diff.insert.to_string(&txn);
84 let mut obj = serde_json::Map::new();
85 obj.insert("insert".to_string(), Value::String(insert));
86 if let Some(attrs) = diff.attributes {
87 obj.insert("attributes".to_string(), attrs_to_json(&attrs));
88 }
89 Value::Object(obj)
90 })
91 .collect();
92 Value::Array(runs)
93}
94
95fn check(name: &str) {
96 let (update, expected_text, expected_delta) = fixture(name);
97 // Never yrs::Doc::new() (draws a random client id) or
98 // yrs::Doc::with_client_id() (still draws a random GUID) — both reach
99 // fastrand entropy that this crate's custom getrandom hook refuses to
100 // fabricate. Supply both the client id and the GUID explicitly via
101 // Options::with_guid_and_client_id instead; this is the construction
102 // docs-core itself must use everywhere (see core/src/lib.rs and
103 // clippy.toml, which bans the other two at the source level).
104 let guid: yrs::Uuid = "wire-compat-test-guid".into();
105 let doc = Doc::with_options(Options::with_guid_and_client_id(guid, 9001));
106 docs_core::apply_update(&doc, &update).expect("apply failed");
107 assert_eq!(
108 docs_core::text_content(&doc),
109 expected_text,
110 "fixture {name} text"
111 );
112 assert_eq!(delta_of(&doc), expected_delta, "fixture {name} delta");
113}
114
115#[test]
116fn applies_hello() {
117 check("hello");
118}
119
120#[test]
121fn applies_unicode() {
122 check("unicode");
123}
124
125#[test]
126fn applies_formatted() {
127 check("formatted");
128}
129
130#[test]
131fn applies_concurrent() {
132 check("concurrent");
133}
134
135// Note: the brief's Step 1 listing calls `docs_core::abi::doc_new()` with no
136// arguments and destructures `fixture()` into a 2-tuple. Neither matches
137// what's actually in this file: `doc_new` takes the GUID and client id from
138// the host (amendment 1 — never drawn from `yrs`, see core/src/lib.rs), and
139// `fixture()` above returns a 3-tuple (update, text, delta). Adjusted here
140// to match; the round-trip and error-code behaviour the brief cares about
141// are unchanged.
142//
143// Fix round 1 on the fragment-migration task: this used to apply the
144// "unicode" fixture (a bare Y.Text on root "content", fixtures/data) and
145// compare against its plain text. That stopped being what this ABI call
146// exercises the moment `abi::text_string` was repointed to read
147// `fragment_text` (root "prosemirror") instead of `text_content` — see
148// that function's own doc comment. A Y.Text-only fixture round-trips
149// through `apply_slice` fine (the update decodes and applies regardless
150// of which root it targets) but reads back as "" through `text_string`
151// now, which would make this test assert the wrong thing quietly. The
152// fixture is a fragment one instead (`fixtures/vectors/fragment/simple.bin`
153// — the same vectors `core/tests/fragment.rs` tests `fragment_text`/
154// `fragment_html` against directly), so this still proves what it always
155// proved: a real Yjs update, applied through the wasm ABI exactly as the
156// Go host calls it, reads back through that same ABI as the text a
157// document actually holds.
158#[test]
159fn abi_round_trips_a_fixture() {
160 let update = fs::read(
161 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
162 .parent()
163 .unwrap()
164 .join("fixtures/vectors/fragment/simple.bin"),
165 )
166 .expect("missing fixtures/vectors/fragment/simple.bin");
167 let expected = "First paragraph.\n\nSecond paragraph.";
168
169 let handle = docs_core::abi::doc_new("abi-round-trips-a-fixture-guid", 42);
170 assert_ne!(handle, 0, "doc_new returned a null handle");
171
172 let rc = docs_core::abi::apply_slice(handle, &update);
173 assert_eq!(rc, 0, "apply returned {rc}");
174
175 // text_string returns None for an unknown handle (fix-round-1 finding
176 // 3) and Some(String) for a live one; this handle was just created, so
177 // unwrap is appropriate here.
178 let got = docs_core::abi::text_string(handle).expect("handle was just created");
179 assert_eq!(got, expected);
180
181 docs_core::abi::doc_free(handle);
182}
183
184// A single, harmless, non-UB-triggering piece of garbage: `0xff` repeated
185// is simply not a valid Yjs v1 update header, so `apply_update` rejects it
186// during ordinary decoding — a clean negative error code, exercised here
187// as a fast sanity check with no wasm runtime required.
188#[test]
189fn abi_rejects_garbage_without_panicking() {
190 let handle = docs_core::abi::doc_new("abi-rejects-garbage-guid", 43);
191 let rc = docs_core::abi::apply_slice(handle, &[0xff, 0xff, 0xff, 0xff]);
192 assert!(
193 rc < 0,
194 "garbage input should return an error code, got {rc}"
195 );
196 docs_core::abi::doc_free(handle);
197}
198
199// Fix-round-1 finding 5's actual mutation fuzz over the real fixtures
200// (flip 1-3 bytes, fixed seed, assert containment) lives in
201// core/tests/wasm_trap_containment.rs, not here. Reason: some mutated
202// inputs reach real undefined behaviour deep in `yrs`'s wire decode (see
203// core/src/abi.rs's module doc comment), and under *native* execution
204// (this file, plain `cargo test`) that UB is not sandboxed the way wasm
205// linear memory is — it can bring down the whole native test process with
206// an uncatchable abort (verified empirically while building this fix: a
207// native-only version of this fuzz hit "thread caused non-unwinding
208// panic. aborting." within a few thousand random 1-3 byte flips, which is
209// not something `catch_unwind` can contain). The actual *deployment*
210// target (wasm32, called through a wasm runtime) does not have this
211// problem — a wasm trap is contained to the single host call, which is
212// exactly the property finding 1 and 5 need proven. So the mutation fuzz
213// runs the real compiled wasm module under `wasmi` instead, where traps
214// are always safely observable as an `Err` rather than a process crash.
215