1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243 | /**
* State-machine transcripts against the committed dist/.
*
* The scenario drivers live in helpers/fakes.mjs and are shared with
* test/vectors/generate.mjs, so this file replays the EXACT same script the TS
* implementation was recorded under and demands the same transcript: emitted
* events, peer-state snapshots, dial addresses, hangUps, published frames.
*
* That is what pins the parts of the port with no pure-function surface — the
* membership machine (PROTOCOL.md §4a/§4b), the log's entry projection and
* author binding (§5), the reaction fold (§5 "Operations") and the board
* element fold — including the JS-truthiness details a Clojure port gets wrong
* by default (`0`, `""` and `undefined` are all falsy in JS, truthy in Clojure).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { load, readVector, priv } from "./helpers/load.mjs";
import { netScript, relayScript, logScript, logSeamsScript, chatScript, boardScript } from "./helpers/fakes.mjs";
const json = (x) => JSON.parse(JSON.stringify(x));
const cryptoMod = await load("lib/crypto.js");
const encMod = await load("lib/encryption.js");
const NET_PRIVATES = [
"dialPeer", "recFor", "onConnChange", "refreshState", "maybeReady", "sweep", "endChannels",
"dropPeer", "hangUpStr", "beacon", "verified", "onTopicMessage", "onPresence", "beaconTo",
"onMsgFrame", "frame", "relayPeerId", "ensureRelay", "scheduleRedial", "setRelayUp", "pubsub",
];
test("the membership state machine (presence, upgrade, stale sweep, stranger drop)", async () => {
const expected = await readVector("net");
const { Net } = await load("lib/net.js", { Net: NET_PRIVATES });
const got = await netScript({ Net, RoomCrypto: cryptoMod.RoomCrypto, priv });
assert.deepEqual(json(got.transcript), expected.transcript);
assert.deepEqual(json(got.calls), expected.calls);
});
test("relay connection management and jittered redial backoff", async () => {
const expected = await readVector("relay");
const { Net } = await load("lib/net.js", { Net: NET_PRIVATES });
assert.deepEqual(json(await relayScript({ Net, RoomCrypto: cryptoMod.RoomCrypto, priv })), expected);
});
test("the log's entry projection, author binding, ingest and DAG walk", async () => {
const expected = await readVector("log");
const { RoomLog } = await load("lib/log.js", {
RoomLog: ["emit", "onUpdate", "sweepUnseen", "authorOf", "canJoinLocally", "identityLocal"],
});
const got = await logScript({
RoomLog,
RoomCrypto: cryptoMod.RoomCrypto,
makeLogEncryption: encMod.makeLogEncryption,
priv,
});
assert.deepEqual(json(got), expected);
});
test("lib/log's six private seams: dedup, sweep order, author binding, locality", async () => {
// logScript above drives the PUBLIC surface and never calls `priv`, so until
// this test existed the six `_` seams were exposed to the harness and
// exercised by nothing in this repo. chat reaches `_onUpdate` through its own
// vector, but chat consumes a sha-pinned install — a regression here would
// surface one repo downstream, after a kit release, or not at all.
const expected = await readVector("log-seams");
const { RoomLog } = await load("lib/log.js", {
RoomLog: ["emit", "onUpdate", "sweepUnseen", "authorOf", "canJoinLocally", "identityLocal"],
});
const got = await logSeamsScript({
RoomLog,
RoomCrypto: cryptoMod.RoomCrypto,
makeLogEncryption: encMod.makeLogEncryption,
priv,
});
assert.deepEqual(json(got), expected);
});
test("the chat reaction fold, message projection and history ordering", async () => {
const expected = await readVector("chat");
const { ChatClient } = await load("chat/index.js", {
ChatClient: ["applyEntry", "onLive", "onPeerState", "applyReaction", "foldReactionsInto", "idFields", "sendHello"],
});
assert.deepEqual(json(await chatScript({ ChatClient, priv })), expected);
});
test("the board element fold with terminal tombstones", async () => {
const expected = await readVector("board");
const { BoardClient } = await load("board/index.js", { BoardClient: ["applyEntry"] });
assert.deepEqual(json(await boardScript({ BoardClient, priv })), expected);
});
/**
* The board fold's convergence contract, asserted directly rather than only
* pinned: the vector above would happily agree with a wrong implementation if
* the fixture had been generated from it. These are the values the DEPLOYED
* BROWSER produces for the same two scripts (board/client/test/vectors/
* projector.json, `duplicate-add-earliest-wins` and `edit-lww-per-field`).
*/
test("the board fold converges regardless of replication order", async () => {
const { BoardClient } = await load("board/index.js", { BoardClient: ["applyEntry"] });
const byLabel = new Map((await boardScript({ BoardClient, priv })).map((s) => [s.label, s]));
const els = (label) => {
const s = byLabel.get(label);
assert.ok(s, `missing script ${label}`);
return s.elements;
};
// the browser's own two scripts, its own two results
assert.deepEqual(els("duplicate-add-earliest-wins"), [
{ id: "x1", k: "note", x: 0, y: 0, w: 100, h: 100, text: "early", c: 0 },
]);
assert.deepEqual(els("edit-lww-per-field"), [
{ id: "x1", k: "note", x: 7, y: 0, w: 100, h: 100, text: "t", c: 3 },
]);
// ORDER INDEPENDENCE — the point of the whole fold policy. Same entry set,
// different arrival order, byte-identical elements() (JSON, so key order too).
for (const [a, b] of [
["duplicate-add-earliest-wins", "duplicate-add-earliest-wins-ascending"],
["duplicate-add-earliest-wins", "duplicate-add-idempotent"],
["duplicate-add-equal-clock-hash-asc", "duplicate-add-equal-clock-hash-desc"],
["edit-lww-per-field", "edit-lww-per-field-reversed"],
["edit-two-fields-older-first", "edit-two-fields-newer-first"],
["edit-same-field-older-first", "edit-same-field-newer-first"],
["edit-equal-clock-hash-asc", "edit-equal-clock-hash-desc"],
]) {
assert.equal(JSON.stringify(els(a)), JSON.stringify(els(b)), `${a} vs ${b}`);
}
// "A moves it while B recolours it" keeps BOTH effects, either order
assert.deepEqual(els("edit-two-fields-older-first"), [
{ id: "x1", k: "note", x: 10, y: 0, w: 100, h: 100, text: "", c: 5 },
]);
// re-ingesting the same entry emits nothing the second time
assert.deepEqual(byLabel.get("duplicate-add-idempotent").events, [["element", "x1", "note", "authorA"]]);
// a patch every field of which lost the fold emits nothing either
assert.equal(byLabel.get("edit-same-field-newer-first").events.length, 2);
});
/**
* Receive-side sanitisation: this kit has NONE, and that is a live divergence
* from the browser, pinned here so it is visible rather than silent.
*
* This kit clamps the colour token when it SENDS (drawStroke/addText do
* `min(max(c,0),7)`) and validates nothing beyond `typeof el.id === "string"`
* when it RECEIVES. The browser does the opposite: its tools send the local
* style raw and every RECEIVER rebuilds the element through
* board/client/src/board/app/ops.cljs `sanitize-element` / `sanitize-patch`.
* So this kit is the only writer that cannot emit an out-of-range token — its
* receive path is untested from that direction — and anything a browser sends
* lands in a bot's elements() unclamped while every canvas shows it clamped.
*
* Not just the colour. For the element below the browser would yield
* {id, k:"stroke", x:1e7, y:0, pts:[1,2], w:0.1, c:7}
* (coordinates clamped to ±1e7, stroke width to 0.1..200, `c` ROUNDED as well
* as clamped to 0..7, unknown fields dropped, and an element of an unknown
* kind or with a non-22-char id rejected outright), and would fold the patch
* as {c:4} alone — 3.7 rounds, a string `w` is not a number, `nope` is not a
* patch field. It also refuses to let a patch touch `id` or `k`, which this
* kit's per-field assignment happily would.
*
* Fixing this is a deliberate behaviour change to elements() and belongs in
* its own commit; the assertions below are today's truth, not an endorsement.
*/
test("receive-side sanitisation is absent (divergence from the browser, pinned)", async () => {
const { BoardClient } = await load("board/index.js", { BoardClient: ["applyEntry"] });
const script = (await boardScript({ BoardClient, priv })).find((s) => s.label === "receive-unsanitised");
assert.ok(script, "missing receive-unsanitised script");
assert.deepEqual(script.elements, [
{
id: "elemid01AAAAAAAAAAAAAA",
k: "stroke",
x: 1e300, // the browser clamps to 1e7
y: 0,
pts: [1, 2],
w: "not-a-number", // the browser drops a non-numeric patch field; w stays 0.1
c: 3.7, // the browser rounds and clamps: 4
junk: "kept", // the browser rebuilds the element and drops this
nope: 1, // ... and this
},
]);
});
/**
* The two fold rules must be INVISIBLE to anything that does not race: no
* duplicate add for a live id, no two edits touching one field. This drives a
* long monotonic transcript through the real client and through a reference
* implementation of the fold as it was BEFORE the rules landed (unconditional
* `_elements.set`, whole-patch `Object.assign`), and demands byte-equal JSON —
* key order included, since `elements()` hands these objects to consumers.
*/
test("the board fold is byte-identical to the pre-rules fold on non-racing input", async () => {
const { BoardClient } = await load("board/index.js", { BoardClient: ["applyEntry"] });
// strictly ascending (clock, hash); every edit follows its add; ids unique
const script = () => [
["h01", 1, { t: "add", ts: 1, name: "a", el: { id: "e1", k: "stroke", x: 1, y: 2, pts: [3, 4], w: 2, c: 0 } }],
["h02", 2, { t: "add", ts: 2, name: "b", el: { id: "e2", k: "text", x: 5, y: 6, text: "hi", size: 16, c: 3 } }],
["h03", 3, { t: "add", ts: 3, name: "b", el: { id: "e3", k: "note", x: 0, y: 0, w: 9, h: 9, text: "n", c: 1 } }],
["h04", 4, { t: "edit", ts: 4, name: "a", id: "e1", p: { w: 8, c: 5 } }],
["h05", 5, { t: "edit", ts: 5, name: "a", id: "e2", p: { text: "bye" } }],
// a patch that INTRODUCES keys: insertion order is part of the bytes
["h06", 6, { t: "edit", ts: 6, name: "a", id: "e3", p: { fill: 2, wrap: 40 } }],
["h07", 7, { t: "edit", ts: 7, name: "a", id: "nope", p: { w: 1 } }],
["h08", 8, { t: "edit", ts: 8, name: "a", id: "e1", p: null }],
["h09", 9, { t: "del", ts: 9, name: "b", ids: ["e3"] }],
// tombstoned: neither fold resurrects it
["h10", 10, { t: "add", ts: 10, name: "a", el: { id: "e3", k: "note", x: 1, y: 1, w: 1, h: 1, text: "z", c: 0 } }],
["h11", 11, { t: "edit", ts: 11, name: "a", id: "e3", p: { x: 99 } }],
["h12", 12, { t: "add", ts: 12, name: "a", el: { k: "stroke" } }],
["h13", 13, { t: "wat", ts: 13 }],
["h14", 14, { t: "del", ts: 14, name: "b" }],
].map(([hash, clock, op]) => ({ hash, from: "authorA", clock, op }));
/** the fold as it stood before the two rules — last write wins, both ways */
const before = (entries) => {
const map = new Map();
const del = new Set();
for (const e of entries) {
const op = e.op;
if (op.t === "add") {
const el = op.el;
if (!el || typeof el.id !== "string" || del.has(el.id)) continue;
map.set(el.id, el);
} else if (op.t === "edit") {
const el = map.get(op.id);
if (el === undefined || !el || !op.p || typeof op.p !== "object") continue;
Object.assign(el, op.p);
} else if (op.t === "del") {
for (const id of op.ids ?? []) {
del.add(id);
map.delete(id);
}
}
}
return [...map.values()];
};
const client = new BoardClient(null, null, "ROOMID", "me");
const apply = priv(client, "applyEntry");
for (const e of script()) apply(e); // stores op.el by reference — fresh script
assert.equal(JSON.stringify(client.elements()), JSON.stringify(before(script())));
});
|