/** * @factory/collaboration — codec tests (TASK-0092). * * Property-tested (fast-check): the block forest ↔ Yjs doc round-trip is * lossless for random forests across all content families; the op diff → * apply pipeline is a no-op when applied to itself; and the * **two-codecs-agree** contract: `@factory/editor`'s autosave diff and this * package's persister diff produce the same wire `BlockOperation[]` for the * same (previous, next) block set. */ import * as fc from "fast-check"; import { describe, expect, it } from "vitest"; import * as Y from "yjs"; import { BLOCK_CONTENT_SCHEMAS, blockOperationSchema, blockTypeSchema, beforePosition, comparePositions, generatePosition, type BlockOperation, type BlockType, type Position, } from "@factory/shared"; import { diffBlocks } from "@factory/editor/codec"; import { applyBlockOperations, applyBlockOperationsToYDoc, base64ToBytes, blockToYElement, blocksToYDoc, bytesToBase64, decodeBase64Yjs, diffBlockSets, encodeDocAsUpdate, encodeDocStateVector, jsonEqual, namedChild, yDocToBlockList, yDocToBlocks, type BlockLike, } from "./codec.js"; import { BLOCK_Y_MAPPINGS, mappingForType } from "./mapping.js"; // --- random forest generation ------------------------------------------------- type Family = "text" | "code" | "bookmark" | "table" | "media" | "embed" | "structural" | "ref"; const FAMILY_TYPES: ReadonlyMap = new Map([ [ "text", ["paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout", "bulleted_list", "numbered_list", "todo", "toggle", "equation"], ], ["code", ["code"]], ["bookmark", ["bookmark"]], ["table", ["table"]], ["media", ["image", "video", "audio", "file"]], ["embed", ["embed"]], ["structural", ["column", "column_list", "divider", "spacer"]], ["ref", ["database", "linked_database", "chart", "form", "synced_block", "child_page", "child_database"]], ]); const ALL_TYPES = blockTypeSchema.options; const TEXT_SAMPLES = [ "", "hello", "line one\nline two", "unicode: café ☕ émojis 日本語", "spaces double", ]; function familyFor(type: BlockType): Family { for (const [kind, types] of FAMILY_TYPES) { if (types.includes(type)) return kind; } throw new Error(`no family for ${type}`); } /** Deterministic PRNG (AGENTS.md rule 25 — the tests are reproducible). */ function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Generate valid content for a block type (schema-compatible values). */ function genContent(rng: () => number, type: BlockType): Record { const pick = (arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)] as T; const kind = familyFor(type); switch (kind) { case "text": return { text: pick(TEXT_SAMPLES) }; case "embed": // The embed schema is { url } only (no text slot — the codec's // mapping derives slots from the shared schema). return { url: "https://example.com/embed" }; case "code": return { text: pick(TEXT_SAMPLES), ...(rng() < 0.5 ? { language: pick(["ts", "python", "rust"]) } : {}), }; case "bookmark": return { url: "https://example.com/page", ...(rng() < 0.5 ? { title: "A bookmark" } : {}), }; case "table": { const rows = Array.from({ length: 1 + Math.floor(rng() * 3) }, () => Array.from({ length: 1 + Math.floor(rng() * 3) }, () => pick(TEXT_SAMPLES)), ); return { rows, ...(rng() < 0.3 ? { width: 800 } : {}) }; } case "media": return { fileId: `fil_${Math.floor(rng() * 1e6).toString(16)}`, ...(rng() < 0.5 ? { caption: "a caption" } : {}), ...(rng() < 0.3 ? { width: 1200 } : {}), }; case "structural": { switch (type) { case "column": return rng() < 0.5 ? { width: 300 } : {}; case "column_list": return { direction: rng() < 0.5 ? "row" : "column" }; case "divider": case "spacer": return rng() < 0.5 ? { height: 24 } : {}; } return {}; } case "ref": { const id = (prefix: string) => `${prefix}_${Math.floor(rng() * 1e6).toString(16)}`; switch (type) { case "database": return { databaseId: id("db") }; case "child_database": return { databaseId: id("db") }; case "linked_database": return rng() < 0.5 ? { databaseId: id("db"), viewId: id("view") } : { databaseId: id("db") }; case "chart": return { chartId: id("chart") }; case "form": return { formId: id("form") }; case "synced_block": return { sourceBlockId: id("blk") }; case "child_page": return { pageId: id("page") }; } return {}; } } } /** A random flat forest: n root blocks, position-ordered. */ function genForest(rng: () => number): BlockLike[] { const n = 1 + Math.floor(rng() * 8); const blocks: BlockLike[] = []; let cursor = ""; for (let i = 0; i < n; i += 1) { cursor = generatePosition(cursor === "" ? undefined : cursor); const type = ALL_TYPES[Math.floor(rng() * ALL_TYPES.length)] as BlockType; blocks.push({ id: `blk_${i}_${Math.floor(rng() * 1e9).toString(16)}`, type, content: genContent(rng, type), parentId: null, position: cursor, ...(rng() < 0.3 ? { properties: { color: pickColor(rng), checked: rng() < 0.5 } } : {}), }); } return blocks; } function pickColor(rng: () => number): string { const colors = ["red", "blue", "green"]; return colors[Math.floor(rng() * colors.length)] as string; } function normalizeBlock(b: BlockLike) { return { id: b.id, type: b.type, content: sortKeys(b.content), parentId: b.parentId, position: b.position, properties: b.properties !== undefined ? sortKeys(b.properties) : undefined, }; } function sortKeys(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeys); if (typeof value === "object" && value !== null) { return Object.fromEntries( Object.entries(value as Record) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => [k, sortKeys(v)]), ); } return value; } /** * The invariant the wire contract actually guarantees after applying a diff's * ops: the same blocks in the same parents in the same RELATIVE order. The * applier recomputes fractional positions from the move/insert ANCHORS (a * wire `move` carries no `position`, and a wire `insert` no `id` — the server * assigns it), so the exact position strings and the inserted block's id are * NOT reproducible. `shapeOf` drops both (keying each sibling by its parent's * rank rather than its id/position) and keeps type + content + properties, so * two applied sets are equal iff they have the same blocks, in the same * parents, in the same relative order, with the same content/properties. */ function shapeOf(blocks: readonly BlockLike[]): Record { const byParent = new Map(); for (const b of blocks) { const list = byParent.get(b.parentId) ?? []; list.push(b); byParent.set(b.parentId, list); } const out: Record = {}; for (const [parentId, list] of byParent) { list.sort((a, b) => comparePositions(a.position, b.position)); list.forEach((b, rank) => { out[`${parentId ?? "ROOT"}::${rank}`] = { type: b.type, content: sortKeys(b.content), properties: b.properties !== undefined ? sortKeys(b.properties) : undefined, }; }); } return out; } const mk = ( id: string, pos: Position, type: BlockType = "paragraph", content: Record = { text: `T${id}` }, extra: Partial = {}, ): BlockLike => ({ id, type, content, parentId: null, position: pos, ...extra, }); // --- round-trip ---------------------------------------------------------------- describe("blocksToYDoc / yDocToBlockList round-trip", () => { it("is lossless for 200 random forests (property)", () => { fc.assert( fc.property(fc.integer({ min: 1, max: 4096 }), (seed) => { const rng = mulberry32(seed); const forest = genForest(rng); const doc = blocksToYDoc(forest, { pageId: "page_test" }); try { const back = yDocToBlockList(doc); expect(back.map(normalizeBlock)).toEqual(forest.map(normalizeBlock)); } finally { doc.destroy(); } }), { numRuns: 200, seed: 0xc0ffee }, ); }); it("reads fragment order (the decode-time truth) and stamps positions", () => { const a = mk("a", generatePosition()); const b = mk("b", generatePosition(a.position)); const doc = blocksToYDoc([a, b]); try { const list = yDocToBlockList(doc); expect(list.map((x) => x.id)).toEqual(["a", "b"]); expect(list.map((x) => x.position)).toEqual([a.position, b.position]); } finally { doc.destroy(); } }); it("throws on duplicate ids and on a missing parent", () => { const a = mk("a", "000"); expect(() => blocksToYDoc([a, { ...a }])).toThrow(/duplicate/); const orphan = mk("o", "001", "paragraph", { text: "O" }, { parentId: "ghost" }); expect(() => blocksToYDoc([a, orphan])).toThrow(/parent/); }); }); // --- yDocToBlocks (minimal ops) -------------------------------------------------- describe("yDocToBlocks (the persister's diff)", () => { it("emits [] for an unchanged doc (idempotency)", () => { const blocks = [mk("a", generatePosition()), mk("b", generatePosition("0001"))]; const doc = blocksToYDoc(blocks); try { expect(yDocToBlocks(doc, blocks)).toEqual([]); } finally { doc.destroy(); } }); it("emits a single content-only update for a text change (minimal patch)", () => { const prev = [mk("a", generatePosition())]; // content { text: "Ta" } const doc = blocksToYDoc(prev); try { const frag = doc.getXmlFragment("blocks"); const el = frag.get(0) as Y.XmlElement; (namedChild(el, "text") as Y.XmlText).insert(0, "edited"); const ops = yDocToBlocks(doc, prev); expect(ops).toEqual([{ op: "update", id: "a", patch: { content: { text: "editedTa" } } }]); // No `properties`, no `position` — the minimal patch. const patch = (ops[0] as { patch: Record }).patch; expect(patch.position).toBeUndefined(); expect(patch.properties).toBeUndefined(); } finally { doc.destroy(); } }); it("emits a property-only update without content", () => { const prev: BlockLike[] = [ mk("a", generatePosition(), "paragraph", { text: "x" }, { properties: { color: "red" } }), ]; const doc = blocksToYDoc(prev); try { const frag = doc.getXmlFragment("blocks"); const el = frag.get(0) as Y.XmlElement; el.setAttribute("p.color", JSON.stringify("green")); const ops = yDocToBlocks(doc, prev); expect(ops).toEqual([{ op: "update", id: "a", patch: { properties: { color: "green" } } }]); } finally { doc.destroy(); } }); it("emits a one-anchor move (the wire contract) for a reorder", () => { const p1 = generatePosition(); const p2 = generatePosition(p1); const p3 = generatePosition(p2); const prev = [mk("a", p1), mk("b", p2), mk("c", p3)]; const beforeP1 = beforePosition(p1); // strictly sorts before p1 (prepend) const doc = blocksToYDoc(prev); try { // Reorder fragment to c, a, b (c takes a new earlier position). A // client move is delete-old + insert-FRESH (the y-prosemirror // snapshot-diff model). Re-inserting the integrated element crashes // yjs 13.6 and `clone(true)` drops the XmlText slots' `name` attr // (YXmlText.clone rebuilds from the delta without attrs), so the // fresh element is built from the block's DATA via blockToYElement // (the same path the server applier uses). const frag = doc.getXmlFragment("blocks"); const cBlock = prev[2]!; const fresh = blockToYElement({ ...cBlock, position: beforeP1, }); frag.delete(2, 1); frag.insert(0, [fresh]); const ops = yDocToBlocks(doc, prev); // c is now at the front of [c, a, b]: it has no predecessor, and its // successor is `a`. The diff anchors on the neighbor it lands BESIDE — // with no `before` (no predecessor) it emits `after: a` ("a is after // me" → I land before a). This is the editor codec's convention // (two-codecs-agree): a block leading its destination siblings uses // `after`. expect(ops.filter((o) => o.op === "move")).toEqual([{ op: "move", id: "c", after: "a" }]); // a and b are untouched (no update ops). expect(ops.filter((o) => o.op === "update")).toEqual([]); } finally { doc.destroy(); } }); it("emits a delete for a removed block (wire order: deletes first)", () => { const p1 = generatePosition(); const p2 = generatePosition(p1); const prev = [mk("a", p1), mk("b", p2)]; const doc = blocksToYDoc(prev); try { const frag = doc.getXmlFragment("blocks"); frag.delete(1, 1); const ops = yDocToBlocks(doc, prev); expect(ops).toEqual([{ op: "delete", id: "b" }]); } finally { doc.destroy(); } }); }); // --- op appliers ----------------------------------------------------------------- describe("applyBlockOperations (plain) / applyBlockOperationsToYDoc (CRDT)", () => { it("applies delete → update → move → insert in wire order", () => { const p1 = generatePosition(); const p2 = generatePosition(p1); const p3 = generatePosition(p2); const blocks = [mk("a", p1), mk("b", p2), mk("c", p3)]; const ops: BlockOperation[] = [ { op: "delete", id: "c" }, { op: "update", id: "b", patch: { content: { text: "new b" } } }, { op: "move", id: "b", after: "a" }, { op: "insert", block: { type: "paragraph", content: { text: "z" }, position: generatePosition() }, after: "b", }, ]; const plain = applyBlockOperations(blocks, ops); // Wire order: deletes → updates → moves → inserts. Under the diff's // neighbors convention (`after:X` = X is the successor, so the block // lands BEFORE X; `before:X` = X is the predecessor → land after X): // 1. delete c → [a, b] // 2. update b text → [a, b("new b")] // 3. move b after a → a is b's successor → b lands before a → [b, a] // 4. insert z after b → b is z's successor → z lands before b → [z, b, a] expect(plain.map((b) => b.id)).toEqual([plain[0]?.id ?? "", "b", "a"]); expect(plain[0]?.content).toEqual({ text: "z" }); expect(plain.find((x) => x.id === "b")?.content).toEqual({ text: "new b" }); // The load-bearing rule: the list order (canonical) equals the // position-sorted order. comparePositions is the only valid comparator // for fractional positions. const byPos = [...plain].sort((x, y) => comparePositions(x.position, y.position)); expect(byPos.map((b) => b.id)).toEqual(plain.map((b) => b.id)); // A re-diff of the settled state against itself is empty (quiescent). expect(diffBlockSets(plain, plain)).toEqual([]); }); it("the two appliers agree (Y.Doc vs plain block list) on a nested tree", () => { const p1 = generatePosition(); const p2 = generatePosition(p1); const p3 = generatePosition(p2); const p4 = generatePosition(p3); const p5 = generatePosition(p4); const blocks: BlockLike[] = [ mk("a", p1), mk("col", p2, "column", { width: 300 }), mk("b", p3), mk("c1", p4, "paragraph", { text: "C1" }, { parentId: "col" }), mk("c2", p5, "paragraph", { text: "C2" }, { parentId: "col" }), ]; const ops: BlockOperation[] = [ { op: "update", id: "a", patch: { content: { text: "edited" } } }, { op: "update", id: "b", patch: { properties: { color: "red" } } }, { op: "move", id: "c2", before: "c1" }, // within the column { op: "insert", // Wire `insert` carries no id and `parentId` is `string | undefined` // (absent = root — the applier resolves it via the anchor anyway). block: { type: "paragraph", content: { text: "n" }, position: p3 }, before: "b", }, { op: "move", id: "c1", before: "b" }, // cross-parent: into the root ]; const doc = blocksToYDoc(blocks); try { applyBlockOperationsToYDoc(doc, ops); const fromDoc = yDocToBlockList(doc); const fromPlain = applyBlockOperations(blocks, ops); // Compare by SHAPE (parents + relative order + content + properties), // not by id: the wire `insert` carries no id, so each applier assigns // its own `blk_…` to the inserted block — the ids can never literally // agree, and that is the recorded wire gap. Everything else must. expect(shapeOf(fromDoc)).toEqual(shapeOf(fromPlain)); // The moved-in block is in the root (cross-parent via the anchor). expect(fromDoc.find((b) => b.id === "c1")?.parentId).toBeNull(); } finally { doc.destroy(); } }); }); // --- two codecs agree (editor autosave vs collaboration persister) ----------------- describe("two codecs agree (the load-bearing contract)", () => { function toEditorBlocks(blocks: BlockLike[]) { return blocks.map((b) => ({ ...b, version: 1 })); } /** Canonicalize an op (move: one anchor — the wire contract — so the two * codecs' anchor choices compare equal when they are the same slot). */ function normOp(op: Record): unknown { if (op.op === "insert") { const block = op.block as Record; return { op: "insert", block: { type: block.type, content: sortKeys(block.content), properties: block.properties !== undefined ? sortKeys(block.properties) : undefined, parentId: block.parentId ?? null, position: block.position, }, }; } if (op.op === "update") { const patch = op.patch as Record; return { op: "update", id: op.id, patch: { ...(patch.content !== undefined ? { content: sortKeys(patch.content) } : {}), ...(patch.properties !== undefined ? { properties: sortKeys(patch.properties) } : {}), ...(patch.position !== undefined ? { position: patch.position } : {}), }, }; } if (op.op === "move") { return { op: "move", id: op.id, anchor: (op.before as string | undefined) ?? (op.after as string | undefined) ?? null, }; } return { op: "delete", id: op.id }; } it("diffBlocks (editor) and diffBlockSets (collaboration) emit identical wire ops", () => { const p1 = generatePosition(); const p2 = generatePosition(p1); const p3 = generatePosition(p2); const p4 = generatePosition(p3); const p5 = generatePosition(p4); const prev: BlockLike[] = [ mk("a", p1), mk("col", p2, "column", { width: 300 }), mk("b", p3), mk("c1", p4, "paragraph", { text: "C1" }, { parentId: "col" }), mk("c2", p5, "paragraph", { text: "C2" }, { parentId: "col" }), ]; // next: a edited; b moved after col (root); c1 moved into root after b; // c2 deleted; d inserted before col. const q1 = generatePosition(); const q2 = generatePosition(q1); const q3 = generatePosition(q2); const q4 = generatePosition(q3); const q5 = generatePosition(q4); const next: BlockLike[] = [ { id: "a", type: "paragraph", content: { text: "edited" }, parentId: null, position: q1 }, mk("col", q2, "column", { width: 300 }), mk("d", q3), mk("b", q4), mk("c1", q5, "paragraph", { text: "C1" }), ]; const editorOps = diffBlocks(toEditorBlocks(prev), toEditorBlocks(next)).operations; const collabOps = diffBlockSets(prev, next); // Both parse against the shared wire schema (the superRefine one-anchor // rule + the per-type content validation on inserts). for (const op of [...editorOps, ...collabOps]) { expect(blockOperationSchema.safeParse(op).success).toBe(true); } expect(collabOps.map(normOp)).toEqual(editorOps.map(normOp)); // Applying the editor's ops to the plain prev set reproduces `next`'s // SHAPE: the same blocks in the same parents in the same relative order // with the same content/properties. The wire `move` op carries no // `position` (the applier recomputes it from the anchors), and the // editor codec's wire `insert` carries no `id` (the server assigns one // for a NEW user-created block — the collab codec's diff, by contrast, // PRESERVES an existing block's id, which the rebase needs; both are // schema-valid since `id` is optional). Either way the exact position // strings and any inserted-block id are NOT reproducible from the wire — // `shapeOf` drops both and keys each sibling by its parent-relative // rank instead. const applied = applyBlockOperations(prev, editorOps); expect(shapeOf(applied)).toEqual(shapeOf(next)); }); it("unchanged sets produce zero ops in both codecs", () => { const prev = [mk("a", generatePosition()), mk("b", generatePosition("0001"))]; expect(diffBlocks(toEditorBlocks(prev), toEditorBlocks(prev)).operations).toEqual([]); expect(diffBlockSets(prev, prev)).toEqual([]); }); it("diff → apply round-trips the next state (100 random mutations, property)", () => { fc.assert( fc.property(fc.integer({ min: 1, max: 4096 }), (seed) => { const rng = mulberry32(seed); const base = genForest(rng); if (base.length < 2) return; // need a survivor + a movable block // One random mutation (delete / edit / property / reorder / // parent-change) on a copy of the base forest. const next = base.map((b) => ({ ...b, content: { ...b.content } as BlockLike["content"] })); const target = next[Math.floor(rng() * next.length)] as BlockLike; const mode = Math.floor(rng() * 5); if (mode === 0) { next.splice(next.indexOf(target), 1); } else if (mode === 1) { // Type-aware content edit: a block type only carries the slots its // mapping defines (a `child_page` has `{pageId}`, no `text`), so a // blind `text` injection would produce schema-INVALID content the // mapping-driven Y.Doc applier rightly drops (the plain applier // blindly keeps it) — testing malformed ops, not the contract. // Text-slot types edit their text; the rest get a full valid // content replacement (still a real content-change op). if (mappingForType(target.type).textSlots.includes("text")) { target.content = { ...target.content, text: `edited ${Math.floor(rng() * 1e6)}`, } as BlockLike["content"]; } else { target.content = genContent(rng, target.type) as BlockLike["content"]; } } else if (mode === 2) { target.properties = { ...(target.properties ?? {}), color: "blue" }; } else { // Reorder: swap target's position with another block's (same or // different parent — the parent-change is the wire-gap case the // appliers both resolve through the anchors). const other = next[Math.floor(rng() * next.length)] as BlockLike; if (other !== target) { const tp = target.position; target.position = other.position; other.position = tp; if (rng() < 0.5) target.parentId = other.parentId; } } const ops = diffBlockSets(base, next); for (const op of ops) { expect(blockOperationSchema.safeParse(op).success).toBe(true); } // Both appliers reproduce `next`'s SHAPE from `base` + `ops`. // The wire `move` carries no `position` (the applier recomputes it // from the anchors) and the reorder/parent-change mutations change a // block's position string without changing its slot, so the exact // position strings are NOT reproducible from the wire — the invariant // is shape: same blocks, same parents, same relative order, same // content/properties. `shapeOf` drops ids + exact positions and keys // each sibling by its parent-relative rank. const plain = applyBlockOperations(base, ops); expect(shapeOf(plain)).toEqual(shapeOf(next)); const doc = blocksToYDoc(base); try { applyBlockOperationsToYDoc(doc, ops); expect(shapeOf(yDocToBlockList(doc))).toEqual(shapeOf(next)); } finally { doc.destroy(); } }), { numRuns: 100, seed: 0xbeef }, ); }); }); // --- base64 (the sync.request / sync.state encoding) ------------------------------- describe("base64 Yjs encoding", () => { it("round-trips a full doc update (encodeDocAsUpdate / decodeBase64Yjs)", () => { const doc = blocksToYDoc([mk("a", generatePosition(), "paragraph", { text: "state" })]); try { const b64 = encodeDocAsUpdate(doc); const bytes = decodeBase64Yjs(b64); const fresh = new Y.Doc(); try { Y.applyUpdate(fresh, bytes); expect(yDocToBlockList(fresh).map((b) => b.id)).toEqual(["a"]); } finally { fresh.destroy(); } } finally { doc.destroy(); } }); it("round-trips arbitrary byte arrays (property) + state vector + empty", () => { const doc = blocksToYDoc([mk("a", generatePosition(), "paragraph", { text: "x" })]); try { const sv = encodeDocStateVector(doc); expect(base64ToBytes(sv).length).toBeGreaterThan(0); expect(Array.from(base64ToBytes(bytesToBase64(new Uint8Array(0))))).toEqual([]); } finally { doc.destroy(); } fc.assert( fc.property(fc.uint8Array({ maxLength: 1000 }), (bytes) => { expect(Array.from(base64ToBytes(bytesToBase64(bytes)))).toEqual(Array.from(bytes)); }), { numRuns: 50, seed: 7 }, ); }); }); // --- structural gate ---------------------------------------------------------------- describe("mapping structural gate (BLOCK_Y_MAPPINGS vs the shared schemas)", () => { it("covers exactly the shared block types", () => { expect(Object.keys(BLOCK_Y_MAPPINGS).sort()).toEqual([...ALL_TYPES].sort()); }); it("textSlots + rows + attrKeys exactly partition each type's content keys", () => { for (const type of ALL_TYPES) { const mapping = mappingForType(type); const schemaShape = Object.keys( (BLOCK_CONTENT_SCHEMAS[type] as { shape?: Record }).shape ?? {}, ); const mapped = [ ...mapping.textSlots, ...(mapping.hasRows ? ["rows"] : []), ...mapping.attrKeys, ].sort(); expect(mapped).toEqual([...schemaShape].sort()); } }); it("throws for an unknown block type", () => { expect(() => mappingForType("not_a_type")).toThrow(/not in the Yjs mapping/); }); it("jsonEqual is a deep deterministic equality", () => { expect(jsonEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true); expect(jsonEqual({ a: 1 }, { a: 2 })).toBe(false); expect(jsonEqual({ a: undefined }, {})).toBe(false); }); });