@cocreate/crdt 1.34.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +1 -1
- package/.github/workflows/automated.yml +18 -7
- package/CHANGELOG.md +668 -634
- package/CONTRIBUTING.md +6 -6
- package/README.md +180 -92
- package/demo/index.html +2 -2
- package/docs/index.html +4 -4
- package/docs/operations.md +159 -0
- package/package.json +11 -10
- package/src/index.js +765 -716
- package/src/runs.js +46 -0
- package/src/selection.js +53 -0
- package/src/sequence.js +160 -0
- package/test/crdt.test.cjs +584 -0
- package/test/selection-adapters.test.cjs +77 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const vm = require('node:vm');
|
|
5
|
+
const field = { array: 'notes', object: 'record', key: 'content', read: 'false', realtime: true };
|
|
6
|
+
function setup({ read, ids, frame = 'local', client = frame } = {}) {
|
|
7
|
+
let counter = 0;
|
|
8
|
+
const events = [], packets = [], requests = [], listeners = {};
|
|
9
|
+
const crud = {
|
|
10
|
+
socket: { clientId: client, frameId: frame,
|
|
11
|
+
listen: (name, fn) => listeners[name] = fn,
|
|
12
|
+
send: packet => packets.push(structuredClone(packet)) },
|
|
13
|
+
getValueFromObject: (object, key) => object?.[key],
|
|
14
|
+
send: async request => {
|
|
15
|
+
requests.push(structuredClone(request));
|
|
16
|
+
return read ? read(request) : { object: [] };
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const context = vm.createContext({ crud, uuid: () => 'uuid',
|
|
20
|
+
ulid: () => ids?.shift() || String(++counter).padStart(26, '0'),
|
|
21
|
+
ulidDecode: id => Number(id.slice(0,10)),
|
|
22
|
+
localStorage: { getItem: () => 'user' }, console,
|
|
23
|
+
crypto: require('node:crypto').webcrypto, TextEncoder,
|
|
24
|
+
CustomEvent: class { constructor(name, options) { this.type=name; this.detail=options.detail; } },
|
|
25
|
+
window: { dispatchEvent: event => events.push(event) }
|
|
26
|
+
});
|
|
27
|
+
const source = fs.readFileSync('src/index.js','utf8').replace(/\r\r\n/g,'\n')
|
|
28
|
+
.replace(/^import .*;\n/gm,'').replace(/export default (.*);/, 'globalThis.api = $1; globalThis.internal = { objects, getName, viewVersion, createSnapshot };');
|
|
29
|
+
const sequence = fs.readFileSync('src/sequence.js','utf8').replace(/^export /gm,'');
|
|
30
|
+
const selection = fs.readFileSync('src/selection.js','utf8').replace(/^export /gm,'');
|
|
31
|
+
const runs = fs.readFileSync('src/runs.js','utf8').replace(/^export /gm,'');
|
|
32
|
+
vm.runInContext(sequence + '\n' + selection + '\n' + runs + '\n' + source,context);
|
|
33
|
+
return { api: context.api, internal: context.internal, events, packets, requests, socket: crud.socket,
|
|
34
|
+
remote: (data, socketId) => listeners.crdt({ data: structuredClone(data), socketId }) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('read=false initializes and preserves empty text after deleting all content', async () => {
|
|
38
|
+
const s=setup(); assert.equal(await s.api.getText(field),'');
|
|
39
|
+
await s.api.updateText({...field,start:0,value:'hello'});
|
|
40
|
+
await s.api.updateText({...field,start:0,length:5,value:''});
|
|
41
|
+
assert.equal(await s.api.getText(field),'');
|
|
42
|
+
assert.equal(s.requests.filter(r=>r.method==='object.read').length,0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('IDs reach peers, duplicates apply once and remote packets are not rebroadcast', async () => {
|
|
46
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'A'});
|
|
47
|
+
const packet=s.packets[0].data;
|
|
48
|
+
assert.ok(packet.id);
|
|
49
|
+
const peer=setup();await peer.api.getText(field);
|
|
50
|
+
packet.frameId='other';peer.remote(packet);peer.remote(packet);
|
|
51
|
+
assert.equal(await peer.api.getText(field),'A');
|
|
52
|
+
assert.equal(peer.packets.length,0);
|
|
53
|
+
assert.equal(peer.internal.objects[peer.internal.getName(field)].changeLog.length,1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('out-of-order delivery converges to ordered replay without duplicate edits', async () => {
|
|
57
|
+
const s=setup();await s.api.getText(field);
|
|
58
|
+
const op=(id,start,value)=>({...field,id:String(id).padStart(26,'0'),frameId:'remote',start,value});
|
|
59
|
+
s.remote(op(1,0,'A'));s.remote(op(3,2,'C'));s.remote(op(2,1,'B'));
|
|
60
|
+
assert.equal(await s.api.getText(field),'ABC');
|
|
61
|
+
assert.equal((await s.internal.viewVersion({...field})).value,'ABC');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('whole replacements notify, broadcast, preserve history and await saving', async () => {
|
|
65
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'old'});
|
|
66
|
+
await s.api.replaceText({...field,value:'new'});
|
|
67
|
+
assert.equal(await s.api.getText(field),'new');
|
|
68
|
+
assert.equal(s.events.at(-1).detail.string,'new');
|
|
69
|
+
assert.equal(s.packets.length,2);
|
|
70
|
+
assert.equal(s.internal.objects[s.internal.getName(field)].changeLog.length,2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('undo and redo invert middle replacements and leave a dense operation log', async () => {
|
|
74
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'abcdef'});
|
|
75
|
+
await s.api.updateText({...field,start:2,length:2,value:'XY'});
|
|
76
|
+
assert.equal(await s.api.getText(field),'abXYef');
|
|
77
|
+
await s.api.undoText(field);assert.equal(await s.api.getText(field),'abcdef');
|
|
78
|
+
await s.api.redoText(field);assert.equal(await s.api.getText(field),'abXYef');
|
|
79
|
+
const log=s.internal.objects[s.internal.getName(field)].changeLog;
|
|
80
|
+
assert.equal(log.length,4);assert.ok(log.every(Boolean));
|
|
81
|
+
assert.equal((await s.internal.viewVersion({...field})).value,'abXYef');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('parallel initial reads share one load and retain remote arrivals during loading', async () => {
|
|
85
|
+
let release;const held=new Promise(resolve=>release=resolve);
|
|
86
|
+
const s=setup({read:request=>request.array==='crdt' ? held : {object:[{content:''}]}});
|
|
87
|
+
const data={...field,read:'true'};
|
|
88
|
+
const a=s.api.getText(data),b=s.api.getText(data);
|
|
89
|
+
s.remote({...data,frameId:'remote',id:'00000000000000000000000001',start:0,value:'R'});
|
|
90
|
+
release({object:[]});
|
|
91
|
+
assert.deepEqual(await Promise.all([a,b]),['R','R']);
|
|
92
|
+
assert.equal(s.requests.filter(r=>r.array==='crdt').length,1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('loading a database seed does not overwrite the first caller edit', async () => {
|
|
96
|
+
const s=setup({read:r=>({object:r.array==='crdt'?[]:[{content:'base'}]})});
|
|
97
|
+
await s.api.updateText({...field,read:'true',start:4,value:'!'});
|
|
98
|
+
assert.equal(await s.api.getText(field),'base!');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('empty replay does not refetch deleted content and same-millisecond IDs stay ordered', async () => {
|
|
102
|
+
const s=setup({ids:['00000000000000000000000009','00000000000000000000000001']});
|
|
103
|
+
await s.api.updateText({...field,start:0,value:'A'});
|
|
104
|
+
await s.api.updateText({...field,start:1,value:'B'});
|
|
105
|
+
assert.equal(await s.api.getText(field),'AB');
|
|
106
|
+
assert.ok(s.packets[1].data.id>s.packets[0].data.id);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('invalid negative positions reject without writing or broadcasting', async () => {
|
|
110
|
+
const s=setup();await assert.rejects(s.api.updateText({...field,start:-1,length:1}),{name:'RangeError'});
|
|
111
|
+
assert.equal(s.packets.length,0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
test('undo follows its range after an earlier remote insertion', async () => {
|
|
116
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'abc'});
|
|
117
|
+
await s.api.updateText({...field,start:1,value:'X'});
|
|
118
|
+
s.remote({...field,id:'00000000000000000000000003',frameId:'remote',start:0,value:'Y'});
|
|
119
|
+
await s.api.undoText(field);
|
|
120
|
+
assert.equal(await s.api.getText(field),'Yabc');
|
|
121
|
+
await s.api.redoText(field);
|
|
122
|
+
assert.equal(await s.api.getText(field),'YaXbc');
|
|
123
|
+
await s.api.undoText(field);
|
|
124
|
+
assert.equal(await s.api.getText(field),'Yabc');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('undo deletes its own characters and preserves a remote insertion', async () => {
|
|
128
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'abcd'});
|
|
129
|
+
s.remote({...field,id:'00000000000000000000000002',frameId:'remote',start:2,value:'X'});
|
|
130
|
+
await s.api.undoText(field);
|
|
131
|
+
assert.equal(await s.api.getText(field),'X');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('concurrent deletes of the same character preserve the other characters', async () => {
|
|
135
|
+
const seed=setup();await seed.api.updateText({...field,start:0,value:'abc'});
|
|
136
|
+
const first=setup({frame:'first',ids:['00000000010000000000000001']}),
|
|
137
|
+
second=setup({frame:'second',ids:['00000000010000000000000002']});
|
|
138
|
+
await first.api.getText(field);await second.api.getText(field);
|
|
139
|
+
const baseline={...seed.packets[0].data,frameId:'seed'};
|
|
140
|
+
first.remote(baseline);second.remote(baseline);
|
|
141
|
+
await first.api.updateText({...field,start:0,length:1,value:''});
|
|
142
|
+
await second.api.updateText({...field,start:0,length:1,value:''});
|
|
143
|
+
assert.notEqual(first.packets[0].data.id,second.packets[0].data.id);
|
|
144
|
+
assert.deepEqual(first.packets[0].data.targets,second.packets[0].data.targets);
|
|
145
|
+
first.remote({...second.packets[0].data,frameId:'second'});
|
|
146
|
+
second.remote({...first.packets[0].data,frameId:'first'});
|
|
147
|
+
assert.equal(await first.api.getText(field),'bc');
|
|
148
|
+
assert.equal(await second.api.getText(field),'bc');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function permutations(items) {
|
|
152
|
+
if (!items.length) return [[]];
|
|
153
|
+
return items.flatMap((item,index)=>permutations(items.filter((_,i)=>i!==index)).map(rest=>[item,...rest]));
|
|
154
|
+
}
|
|
155
|
+
async function baseline(text='abc') {
|
|
156
|
+
const seed=setup({frame:'seed'});await seed.api.updateText({...field,start:0,value:text});
|
|
157
|
+
return seed.packets[0].data;
|
|
158
|
+
}
|
|
159
|
+
async function writer(frame, base, start, length, value) {
|
|
160
|
+
const s=setup({frame,ids:[`0000000001${frame.padStart(16,'0')}`]});
|
|
161
|
+
await s.api.getText(field);s.remote(base);
|
|
162
|
+
await s.api.updateText({...field,start,length,value});
|
|
163
|
+
return s;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test('concurrent insert and delete preserve unobserved inserted text in every delivery order', async () => {
|
|
167
|
+
const base=await baseline();
|
|
168
|
+
const deletion=await writer('A',base,0,1,'');
|
|
169
|
+
const insertion=await writer('B',base,1,0,'X');
|
|
170
|
+
for (const order of permutations([base,deletion.packets[0].data,insertion.packets[0].data])) {
|
|
171
|
+
const peer=setup();await peer.api.getText(field);
|
|
172
|
+
for (const op of order) peer.remote(op);
|
|
173
|
+
assert.equal(await peer.api.getText(field),'Xbc');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('repeated characters have distinct identities and concurrent runs remain contiguous', async () => {
|
|
178
|
+
const base=await baseline('aaa');
|
|
179
|
+
const left=await writer('A',base,0,1,'');
|
|
180
|
+
const right=await writer('B',base,1,1,'');
|
|
181
|
+
const peer=setup();await peer.api.getText(field);peer.remote(base);
|
|
182
|
+
peer.remote(left.packets[0].data);peer.remote(right.packets[0].data);
|
|
183
|
+
assert.equal(await peer.api.getText(field),'a');
|
|
184
|
+
const a=await writer('C',base,1,0,'XY'),b=await writer('D',base,1,0,'12');
|
|
185
|
+
const results=[];
|
|
186
|
+
for (const order of [[a,b],[b,a]]) {
|
|
187
|
+
const p=setup();await p.api.getText(field);p.remote(base);
|
|
188
|
+
for(const w of order)p.remote(w.packets[0].data);
|
|
189
|
+
const text=await p.api.getText(field);
|
|
190
|
+
assert.ok(text.includes('XY'));assert.ok(text.includes('12'));results.push(text);
|
|
191
|
+
}
|
|
192
|
+
assert.equal(results[0],results[1]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('selective undo of a concurrent deletion does not undo someone else deletion', async () => {
|
|
196
|
+
const base=await baseline();
|
|
197
|
+
const a=await writer('A',base,0,1,''),b=await writer('B',base,0,1,'');
|
|
198
|
+
a.remote(b.packets[0].data);
|
|
199
|
+
await a.api.undoText(field);
|
|
200
|
+
assert.equal(await a.api.getText(field),'bc');
|
|
201
|
+
await a.api.redoText(field);
|
|
202
|
+
assert.equal(await a.api.getText(field),'bc');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('identical database bootstrap has identical character identities across clients', async () => {
|
|
206
|
+
const read=r=>({object:r.array==='crdt'?[]:[{content:'abc'}]});
|
|
207
|
+
const a=setup({frame:'A',read}),b=setup({frame:'B',read});
|
|
208
|
+
const data={...field,read:'true'};
|
|
209
|
+
await Promise.all([a.api.getText(data),b.api.getText(data)]);
|
|
210
|
+
assert.equal(a.packets[0].data.id,b.packets[0].data.id);
|
|
211
|
+
a.remote(b.packets[0].data);b.remote(a.packets[0].data);
|
|
212
|
+
assert.equal(await a.api.getText(field),'abc');
|
|
213
|
+
assert.equal(await b.api.getText(field),'abc');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('identity snapshots retain deleted anchors, accept delayed edits and reload correctly', async () => {
|
|
217
|
+
const base=await baseline();
|
|
218
|
+
const late=await writer('A',base,1,0,'X');
|
|
219
|
+
const s=setup();await s.api.getText(field);s.remote(base);
|
|
220
|
+
await s.api.updateText({...field,start:0,length:1,value:''});
|
|
221
|
+
await s.api.updateText({...field,start:2,length:0,value:'!'});
|
|
222
|
+
const compacted=await s.internal.createSnapshot({...field,save:'false'},2,0);
|
|
223
|
+
assert.ok(compacted[0].sequence);
|
|
224
|
+
s.remote(late.packets[0].data);
|
|
225
|
+
assert.equal(await s.api.getText(field),'Xbc!');
|
|
226
|
+
const log=s.internal.objects[s.internal.getName(field)].changeLog;
|
|
227
|
+
const reloaded=setup({read:r=>({object:r.array==='crdt'?[{changeLog:structuredClone(log)}]:[]})});
|
|
228
|
+
assert.equal(await reloaded.api.getText({...field,read:'true'}),'Xbc!');
|
|
229
|
+
const count=reloaded.internal.objects[reloaded.internal.getName(field)].changeLog.length;
|
|
230
|
+
reloaded.remote(late.packets[0].data);
|
|
231
|
+
assert.equal(reloaded.internal.objects[reloaded.internal.getName(field)].changeLog.length,count);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('concurrent replacements delete only observed characters and preserve both inserted runs', async () => {
|
|
235
|
+
const base=await baseline('abcdef');
|
|
236
|
+
const a=await writer('A',base,1,3,'XX'),b=await writer('B',base,2,3,'YY');
|
|
237
|
+
const outputs=[];
|
|
238
|
+
for(const order of permutations([base,a.packets[0].data,b.packets[0].data])) {
|
|
239
|
+
const p=setup();await p.api.getText(field);
|
|
240
|
+
for(const change of order)p.remote(change);
|
|
241
|
+
const text=await p.api.getText(field);
|
|
242
|
+
assert.ok(text.includes('XX'));assert.ok(text.includes('YY'));
|
|
243
|
+
assert.ok(text.startsWith('a'));assert.ok(text.endsWith('f'));outputs.push(text);
|
|
244
|
+
}
|
|
245
|
+
assert.equal(new Set(outputs).size,1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('snapshot persistence appends identity state instead of overwriting concurrent log writes', async () => {
|
|
249
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'abc'});
|
|
250
|
+
await s.api.updateText({...field,start:1,length:1,value:''});
|
|
251
|
+
await s.api.updateText({...field,start:2,value:'!'});
|
|
252
|
+
await s.internal.createSnapshot(field,2,0);
|
|
253
|
+
const request=s.requests.find(r=>r.metadata==='crdt-snapshot');
|
|
254
|
+
assert.ok(request.object['$push.changeLog'].sequence);
|
|
255
|
+
assert.equal(request.object.changeLog,undefined);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('UTF-16 offsets delete one emoji without damaging following characters', async () => {
|
|
259
|
+
const base=await baseline('😀bc');
|
|
260
|
+
const a=await writer('A',base,0,2,''),b=await writer('B',base,0,2,'');
|
|
261
|
+
a.remote(b.packets[0].data);b.remote(a.packets[0].data);
|
|
262
|
+
assert.equal(await a.api.getText(field),'bc');assert.equal(await b.api.getText(field),'bc');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('UI events use a current visible splice even when wire operations target old positions', async () => {
|
|
266
|
+
const base=await baseline();
|
|
267
|
+
const a=await writer('A',base,0,1,''),b=await writer('B',base,1,0,'X');
|
|
268
|
+
const p=setup();await p.api.getText(field);p.remote(base);p.remote(b.packets[0].data);
|
|
269
|
+
const before=await p.api.getText(field);p.remote(a.packets[0].data);
|
|
270
|
+
const {start,length,value,string}=p.events.at(-1).detail;
|
|
271
|
+
assert.equal(before.slice(0,start)+value+before.slice(start+length),string);
|
|
272
|
+
assert.equal(string,'Xbc');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('three writers converge after interleaved edits, duplicate packets and reversed delivery', async () => {
|
|
276
|
+
const base=await baseline('repeated text repeated text');
|
|
277
|
+
const writers=['A','B','C'].map(frame=>setup({frame,
|
|
278
|
+
ids:Array.from({length:100},(_,i)=>String(i+1).padStart(10,'0')+frame.repeat(16))}));
|
|
279
|
+
for(const writer of writers) { await writer.api.getText(field);writer.remote(base); }
|
|
280
|
+
let random=123456;
|
|
281
|
+
const next=max=>{random=(Math.imul(random,1664525)+1013904223)>>>0;return random%max;};
|
|
282
|
+
const packets=[base];
|
|
283
|
+
for(let step=0;step<60;step++) {
|
|
284
|
+
const index=next(3),writer=writers[index];
|
|
285
|
+
const text=await writer.api.getText(field),start=next(text.length+1);
|
|
286
|
+
await writer.api.updateText({...field,start,length:Math.min(next(3),text.length-start),value:next(2)?String(step):''});
|
|
287
|
+
const packet=writer.packets.at(-1).data;packets.push(packet);
|
|
288
|
+
const receiver=writers[next(3)];receiver.remote(packet);
|
|
289
|
+
}
|
|
290
|
+
for(let i=0;i<writers.length;i++) {
|
|
291
|
+
const delivery=i%2?packets:packets.slice().reverse();
|
|
292
|
+
for(const packet of delivery) { writers[i].remote(packet);writers[i].remote(packet); }
|
|
293
|
+
}
|
|
294
|
+
const results=await Promise.all(writers.map(writer=>writer.api.getText(field)));
|
|
295
|
+
assert.equal(new Set(results).size,1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('consecutive typing references the first operation without generating another run ID', async () => {
|
|
299
|
+
const s=setup();
|
|
300
|
+
await s.api.updateText({...field,start:0,value:'A'});
|
|
301
|
+
await s.api.updateText({...field,start:1,value:'B'});
|
|
302
|
+
await s.api.updateText({...field,start:2,value:'C'});
|
|
303
|
+
const [a,b,c]=s.packets.map(packet=>packet.data);
|
|
304
|
+
assert.equal(a.runId,a.id);assert.equal(b.runId,a.id);assert.equal(c.runId,a.id);
|
|
305
|
+
assert.deepEqual([a.runIndex,b.runIndex,c.runIndex],[0,1,2]);
|
|
306
|
+
assert.equal(b.runLeft,a.runLeft);assert.equal(b.runRight,a.runRight);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('explicit run end, reposition, deletion and whole replacement establish boundaries', async () => {
|
|
310
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'A'});
|
|
311
|
+
const first=s.packets.at(-1).data.runId;
|
|
312
|
+
s.api.endRun(field);await s.api.updateText({...field,start:1,value:'B'});
|
|
313
|
+
assert.notEqual(s.packets.at(-1).data.runId,first);
|
|
314
|
+
const second=s.packets.at(-1).data.runId;
|
|
315
|
+
await s.api.updateText({...field,start:0,value:'C'});
|
|
316
|
+
assert.notEqual(s.packets.at(-1).data.runId,second);
|
|
317
|
+
await s.api.updateText({...field,start:0,length:1,value:''});
|
|
318
|
+
assert.equal(s.packets.at(-1).data.runId,undefined);
|
|
319
|
+
await s.api.replaceText({...field,value:'new'});
|
|
320
|
+
const replacement=s.packets.at(-1).data;
|
|
321
|
+
await s.api.updateText({...field,start:3,value:'!'});
|
|
322
|
+
assert.notEqual(s.packets.at(-1).data.runId,replacement.runId);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('four client typing runs stay grouped and ordered by client ID in reversed delivery', async () => {
|
|
326
|
+
const base=await baseline();const packets=[base];
|
|
327
|
+
for(const frame of ['D','B','A','C']) {
|
|
328
|
+
const w=setup({frame,ids:Array.from({length:8},(_,i)=>String(i+1).padStart(10,'0')+frame.repeat(16))});
|
|
329
|
+
await w.api.getText(field);w.remote(base);
|
|
330
|
+
for(let i=0;i<3;i++)await w.api.updateText({...field,start:1+i,value:frame});
|
|
331
|
+
packets.push(...w.packets.map(packet=>packet.data));
|
|
332
|
+
}
|
|
333
|
+
const orders=[packets,packets.slice().reverse(),packets.slice().sort((a,b)=>a.id.localeCompare(b.id))];
|
|
334
|
+
for(const order of orders) {
|
|
335
|
+
const p=setup();await p.api.getText(field);
|
|
336
|
+
for(const op of order)p.remote(op);
|
|
337
|
+
assert.equal(await p.api.getText(field),'aAAABBBCCCDDDbc');
|
|
338
|
+
assert.equal((await p.internal.viewVersion(field)).value,'aAAABBBCCCDDDbc');
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('two frames of the same browser use distinct runs with a stable run-ID tie-breaker', async () => {
|
|
343
|
+
const base=await baseline();const packets=[base];
|
|
344
|
+
for(const [frame,suffix,value] of [['tabB','B','Y'],['tabA','A','X']]) {
|
|
345
|
+
const w=setup({frame,client:'browser',ids:Array.from({length:4},(_,i)=>String(i+1).padStart(10,'0')+suffix.repeat(16))});
|
|
346
|
+
await w.api.getText(field);w.remote(base);
|
|
347
|
+
for(let i=0;i<2;i++)await w.api.updateText({...field,start:1+i,value});
|
|
348
|
+
packets.push(...w.packets.map(packet=>packet.data));
|
|
349
|
+
}
|
|
350
|
+
for(const order of [packets,packets.slice().reverse()]) {
|
|
351
|
+
const p=setup();await p.api.getText(field);for(const op of order)p.remote(op);
|
|
352
|
+
assert.equal(await p.api.getText(field),'aXXYYbc');
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('observed insertion inside a run overrides client priority', async () => {
|
|
357
|
+
const base=await baseline();const a=await writer('A',base,1,0,'XX');
|
|
358
|
+
const z=setup({frame:'Z'});await z.api.getText(field);z.remote(base);z.remote(a.packets[0].data);
|
|
359
|
+
await z.api.updateText({...field,start:2,value:'Y'});
|
|
360
|
+
const p=setup();await p.api.getText(field);
|
|
361
|
+
for(const op of [z.packets[0].data,a.packets[0].data,base])p.remote(op);
|
|
362
|
+
assert.equal(await p.api.getText(field),'aXYXbc');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('local edits read the live Socket Client identity instead of generating a browser ID', async () => {
|
|
366
|
+
const s=setup();s.socket.clientId=undefined;
|
|
367
|
+
await assert.rejects(s.api.updateText({...field,start:0,value:'A'}),/must initialize/);
|
|
368
|
+
s.socket.clientId='initialized-browser';
|
|
369
|
+
await s.api.updateText({...field,start:0,value:'A'});
|
|
370
|
+
assert.equal(s.packets[0].data.clientId,'initialized-browser');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('foreign paste into a run starts a fresh local run without an editor endRun call', async () => {
|
|
374
|
+
const a=setup({frame:'A'});await a.api.updateText({...field,start:0,value:'AB'});
|
|
375
|
+
const firstRun=a.packets[0].data.runId;
|
|
376
|
+
const b=setup({frame:'B'});await b.api.getText(field);b.remote(a.packets[0].data);
|
|
377
|
+
await b.api.updateText({...field,start:1,value:'PASTE'});
|
|
378
|
+
a.remote(b.packets[0].data);
|
|
379
|
+
assert.equal(await a.api.getText(field),'APASTEB');
|
|
380
|
+
await a.api.updateText({...field,start:7,value:'C'});
|
|
381
|
+
assert.notEqual(a.packets.at(-1).data.runId,firstRun);
|
|
382
|
+
assert.equal(a.packets.at(-1).data.runIndex,0);
|
|
383
|
+
assert.equal(await a.api.getText(field),'APASTEBC');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('incoming insertion before a run breaks numeric sequential positioning', async () => {
|
|
387
|
+
const a=setup({frame:'A'});await a.api.updateText({...field,start:0,value:'AB'});
|
|
388
|
+
const first=a.packets[0].data.runId;
|
|
389
|
+
const b=setup({frame:'B'});await b.api.getText(field);b.remote(a.packets[0].data);
|
|
390
|
+
await b.api.updateText({...field,start:0,value:'X'});a.remote(b.packets[0].data);
|
|
391
|
+
await a.api.updateText({...field,start:3,value:'C'});
|
|
392
|
+
assert.notEqual(a.packets.at(-1).data.runId,first);
|
|
393
|
+
assert.equal(await a.api.getText(field),'XABC');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('an unrelated foreign edit after the run preserves its next-position contract', async () => {
|
|
397
|
+
const base=await baseline('abc');
|
|
398
|
+
const a=await writer('A',base,1,0,'X');
|
|
399
|
+
const first=a.packets[0].data.runId;
|
|
400
|
+
const b=setup({frame:'B'});await b.api.getText(field);b.remote(base);b.remote(a.packets[0].data);
|
|
401
|
+
await b.api.updateText({...field,start:4,value:'Z'});a.remote(b.packets[0].data);
|
|
402
|
+
await a.api.updateText({...field,start:2,value:'Y'});
|
|
403
|
+
assert.equal(a.packets.at(-1).data.runId,first);
|
|
404
|
+
assert.equal(await a.api.getText(field),'aXYbcZ');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('edits carry the actual backward selection using identities in one packet', async () => {
|
|
408
|
+
const s=setup();
|
|
409
|
+
await s.api.updateText({...field,start:0,value:'abc', selection:{anchor:3,head:1}});
|
|
410
|
+
assert.equal(s.packets.length,1);
|
|
411
|
+
const data=s.packets[0].data;
|
|
412
|
+
assert.equal(data.selection.anchor.left,`${data.id}:2`);
|
|
413
|
+
assert.equal(data.selection.head.right,`${data.id}:1`);
|
|
414
|
+
const event=s.events.filter(e=>e.type==='cocreate-crdt-selection').at(-1).detail;
|
|
415
|
+
assert.deepEqual({...event.resolved},{anchor:3,head:1,start:1,end:3});
|
|
416
|
+
assert.ok(s.requests.find(r=>r.metadata==='crdt-change').object['$push.changeLog'].selection);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('selection-only movements and clearing do not save text or change undo history', async () => {
|
|
420
|
+
const s=setup();await s.api.updateText({...field,start:0,value:'abc'});
|
|
421
|
+
const writes=s.requests.length, object=s.internal.objects[s.internal.getName(field)];
|
|
422
|
+
await s.api.updateSelection({...field,selection:{anchor:1,head:2}});
|
|
423
|
+
await s.api.updateSelection({...field,selection:null});
|
|
424
|
+
assert.equal(s.packets.at(-1).data.type,'selection');
|
|
425
|
+
assert.equal(s.requests.length,writes);
|
|
426
|
+
assert.equal(object.changeLog.length,1);assert.equal(object.undoStack.length,1);
|
|
427
|
+
assert.equal(s.events.at(-1).detail.selection,null);
|
|
428
|
+
assert.equal(await s.api.getText(field),'abc');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('selection endpoints follow concurrent insertions and deleted neighbors', async () => {
|
|
432
|
+
const a=setup({frame:'a'}),b=setup({frame:'b'});
|
|
433
|
+
await a.api.updateText({...field,start:0,value:'abcd'});
|
|
434
|
+
await b.api.getText(field);b.remote(a.packets[0].data);
|
|
435
|
+
await a.api.updateSelection({...field,selection:{anchor:3,head:1}});
|
|
436
|
+
b.remote(a.packets.at(-1).data);
|
|
437
|
+
await b.api.updateText({...field,start:0,value:'XX'});
|
|
438
|
+
let event=b.events.filter(e=>e.type==='cocreate-crdt-selection'&&e.detail.frameId==='a').at(-1);
|
|
439
|
+
assert.deepEqual({...event.detail.resolved},{anchor:5,head:3,start:3,end:5});
|
|
440
|
+
await b.api.updateText({...field,start:2,length:3,value:''});
|
|
441
|
+
event=b.events.filter(e=>e.type==='cocreate-crdt-selection'&&e.detail.frameId==='a').at(-1);
|
|
442
|
+
assert.deepEqual({...event.detail.resolved},{anchor:2,head:2,start:2,end:2});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('presence delivered before its characters waits and older presence cannot replace it', async () => {
|
|
446
|
+
const a=setup({frame:'a'}),b=setup({frame:'b'});
|
|
447
|
+
await a.api.updateText({...field,start:0,value:'abc',selection:{anchor:1,head:1}});
|
|
448
|
+
await a.api.updateSelection({...field,selection:{anchor:3,head:2}});
|
|
449
|
+
await b.api.getText(field);
|
|
450
|
+
b.remote(a.packets[1].data);
|
|
451
|
+
assert.equal(b.events.filter(e=>e.type==='cocreate-crdt-selection').length,0);
|
|
452
|
+
b.remote(a.packets[0].data);
|
|
453
|
+
assert.deepEqual({...b.events.at(-1).detail.resolved},{anchor:3,head:2,start:2,end:3});
|
|
454
|
+
assert.equal(b.packets.length,0);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('captureSelection runs after the UI text event and is never serialized', async () => {
|
|
458
|
+
const s=setup();
|
|
459
|
+
await s.api.updateText({...field,start:0,value:'abc',captureSelection:()=>{
|
|
460
|
+
assert.equal(s.events.at(-1).type,'cocreate-crdt-update');
|
|
461
|
+
assert.equal(s.events.at(-1).detail.string,'abc');
|
|
462
|
+
return {anchor:2,head:1};
|
|
463
|
+
}});
|
|
464
|
+
assert.equal(s.packets.length,1);
|
|
465
|
+
assert.ok(s.packets[0].data.selection);
|
|
466
|
+
assert.equal('captureSelection' in s.packets[0].data,false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('invalid explicit selection rejects without committing the text', async () => {
|
|
470
|
+
const s=setup();
|
|
471
|
+
await assert.rejects(s.api.updateText({...field,start:0,value:'a',selection:{anchor:2,head:0}}),/selection/);
|
|
472
|
+
assert.equal(await s.api.getText(field),'');assert.equal(s.packets.length,0);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('selection references survive snapshots and undo of deleted neighbors', async () => {
|
|
476
|
+
const s=setup();
|
|
477
|
+
await s.api.updateText({...field,start:0,value:'abcd'});
|
|
478
|
+
await s.api.updateSelection({...field,selection:{anchor:3,head:1}});
|
|
479
|
+
await s.api.updateText({...field,start:1,length:2,value:''});
|
|
480
|
+
await s.api.updateText({...field,start:2,value:'!'});
|
|
481
|
+
await s.internal.createSnapshot(field,2,0);
|
|
482
|
+
await s.api.updateText({...field,start:0,value:'X'});
|
|
483
|
+
let selection=s.events.filter(e=>e.type==='cocreate-crdt-selection').at(-1).detail;
|
|
484
|
+
assert.deepEqual({...selection.resolved},{anchor:2,head:2,start:2,end:2});
|
|
485
|
+
await s.api.undoText(field);
|
|
486
|
+
selection=s.events.filter(e=>e.type==='cocreate-crdt-selection').at(-1).detail;
|
|
487
|
+
assert.deepEqual({...selection.resolved},{anchor:1,head:1,start:1,end:1});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('loaded selections anchor synchronously and resolve locally after a remote edit', async () => {
|
|
491
|
+
const a=setup({frame:'a'}), b=setup({frame:'b'});
|
|
492
|
+
await a.api.updateText({...field,start:0,value:'abc'});
|
|
493
|
+
await b.api.getText(field); b.remote(a.packets[0].data, 'socket-a');
|
|
494
|
+
const sharing=b.api.updateSelection({...field,selection:{anchor:3,head:1}});
|
|
495
|
+
assert.deepEqual({...b.api.getSelection(field)},{anchor:3,head:1,start:1,end:3});
|
|
496
|
+
await a.api.updateText({...field,start:0,value:'X'});
|
|
497
|
+
b.remote(a.packets[1].data, 'socket-a');
|
|
498
|
+
await sharing;
|
|
499
|
+
assert.deepEqual({...b.api.getSelection(field)},{anchor:4,head:2,start:2,end:4});
|
|
500
|
+
await b.api.updateSelection({...field,selection:null});
|
|
501
|
+
assert.equal(b.api.getSelection(field),null);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('presence snapshots preserve identity, resolve late subscriptions and protect stored anchors', async () => {
|
|
505
|
+
const a=setup({frame:'a'}), b=setup({frame:'b'});
|
|
506
|
+
await a.api.updateText({...field,start:0,value:'abc',selection:{anchor:1,head:2},presence:{userName:'Alice'}});
|
|
507
|
+
await b.api.getText(field); b.remote(a.packets[0].data,'socket-a');
|
|
508
|
+
const entry=b.api.getSelections(field)[0];
|
|
509
|
+
assert.equal(entry.socketId,'socket-a');
|
|
510
|
+
assert.equal(entry.presence.userName,'Alice');
|
|
511
|
+
assert.deepEqual({...entry.resolved},{anchor:1,head:2,start:1,end:2});
|
|
512
|
+
entry.selection.anchor.left='tampered'; entry.presence.userName='tampered';
|
|
513
|
+
assert.equal(b.api.getSelections(field)[0].presence.userName,'Alice');
|
|
514
|
+
assert.equal(b.api.getSelections(field)[0].resolved.anchor,1);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('duplicate cursor reports do not send packets or split a typing run', async () => {
|
|
518
|
+
const s=setup();
|
|
519
|
+
const presence={userName:'Writer'};
|
|
520
|
+
await s.api.updateText({...field,start:0,value:'a',selection:{anchor:1,head:1},presence});
|
|
521
|
+
const count=s.packets.length;
|
|
522
|
+
await s.api.updateSelection({...field,selection:{anchor:1,head:1},presence});
|
|
523
|
+
assert.equal(s.packets.length,count);
|
|
524
|
+
await s.api.updateSelection({...field,selection:{anchor:1,head:1},presence:{userName:'Renamed'}});
|
|
525
|
+
assert.equal(s.packets.length,count+1);
|
|
526
|
+
await s.api.updateText({...field,start:1,value:'b'});
|
|
527
|
+
assert.equal(s.packets.at(-1).data.runId,s.packets[0].data.runId);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('malformed presence clocks and references cannot overwrite known selections', async () => {
|
|
531
|
+
const a=setup({frame:'a'}), b=setup({frame:'b'});
|
|
532
|
+
await a.api.updateText({...field,start:0,value:'abc',selection:{anchor:1,head:1}});
|
|
533
|
+
await b.api.getText(field); b.remote(a.packets[0].data,'socket-a');
|
|
534
|
+
const good=a.packets[0].data;
|
|
535
|
+
for(const selectionClock of [undefined,0,-1,NaN,Infinity,'100'])
|
|
536
|
+
b.remote({...good,type:'selection',selectionClock,selection:null},'socket-a');
|
|
537
|
+
b.remote({...good,type:'selection',selectionClock:2,selection:{}},'socket-a');
|
|
538
|
+
assert.equal(b.api.getSelections(field)[0].resolved.anchor,1);
|
|
539
|
+
await a.api.updateSelection({...field,selection:{anchor:3,head:3}});
|
|
540
|
+
b.remote(a.packets.at(-1).data,'socket-a');
|
|
541
|
+
assert.equal(b.api.getSelections(field)[0].resolved.anchor,3);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('invalid presence in a text packet never drops its valid text operation', async () => {
|
|
545
|
+
const a=setup({frame:'a'}), b=setup({frame:'b'});
|
|
546
|
+
await a.api.updateText({...field,start:0,value:'abc',selection:{anchor:1,head:1}});
|
|
547
|
+
await b.api.getText(field);
|
|
548
|
+
b.remote({...a.packets[0].data,selection:{},selectionClock:Infinity},'socket-a');
|
|
549
|
+
assert.equal(await b.api.getText(field),'abc');
|
|
550
|
+
assert.equal(b.api.getSelections(field).length,0);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('private drafts retain CRDT history without notifying peers or storage', async () => {
|
|
554
|
+
const s=setup({frame:null,client:null});
|
|
555
|
+
const element={id:'editor-a'};
|
|
556
|
+
const draft={...field,realtime:false,scope:'editor-a',initialValue:'abc',element};
|
|
557
|
+
const other={...field,realtime:false,scope:'editor-b',initialValue:'abc'};
|
|
558
|
+
assert.equal(await s.api.getText(draft),'abc');
|
|
559
|
+
assert.equal(await s.api.getText(other),'abc');
|
|
560
|
+
await s.api.updateText({...draft,start:1,value:'X'});
|
|
561
|
+
assert.equal(await s.api.getText(draft),'aXbc');
|
|
562
|
+
assert.equal(await s.api.getText(other),'abc');
|
|
563
|
+
await s.api.undoText(draft);
|
|
564
|
+
assert.equal(await s.api.getText(draft),'abc');
|
|
565
|
+
await s.api.redoText(draft);
|
|
566
|
+
assert.equal(await s.api.getText(draft),'aXbc');
|
|
567
|
+
assert.deepEqual(s.events.map(event=>event.detail.string),['aXbc','abc','aXbc']);
|
|
568
|
+
assert.ok(s.events.every(event=>event.detail.elements[0]===element));
|
|
569
|
+
assert.equal(s.packets.length,0);
|
|
570
|
+
assert.equal(s.requests.length,0);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('realtime must be explicitly true before CRDT publishes a change', async () => {
|
|
574
|
+
const s=setup();
|
|
575
|
+
const local={array:'notes',object:'record',key:'content',read:'false',scope:'editor'};
|
|
576
|
+
await s.api.updateText({...local,start:0,value:'draft'});
|
|
577
|
+
assert.equal(await s.api.getText(local),'draft');
|
|
578
|
+
assert.equal(s.packets.length,0);
|
|
579
|
+
assert.equal(s.requests.length,0);
|
|
580
|
+
assert.equal(s.events.length,0);
|
|
581
|
+
await s.api.replaceText({...field,value:'published'});
|
|
582
|
+
assert.equal(s.packets.length,1);
|
|
583
|
+
assert.equal(s.events.at(-1).detail.string,'published');
|
|
584
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const {test}=require('node:test');
|
|
2
|
+
const assert=require('node:assert/strict');
|
|
3
|
+
const fs=require('node:fs');
|
|
4
|
+
const vm=require('node:vm');
|
|
5
|
+
|
|
6
|
+
test('Text captures the resulting selection once and suppresses duplicate cursor packets',()=>{
|
|
7
|
+
const source=fs.readFileSync('../CoCreate-text/src/index.js','utf8').replace(/\r\n/g,'\n');
|
|
8
|
+
const adapter=source.slice(source.indexOf('let previousPosition'),source.indexOf('function updateElements'))
|
|
9
|
+
.replace('export function','function');
|
|
10
|
+
const listeners={}, edits=[],positions=[];
|
|
11
|
+
const element={tagName:'TEXTAREA',start:2,end:2,hasAttribute:()=>true,ownerDocument:{documentElement:{}}};
|
|
12
|
+
const context=vm.createContext({
|
|
13
|
+
window:{addEventListener:(name,fn)=>listeners[name]=fn},
|
|
14
|
+
getAttributes:()=>({array:'notes',object:'record',key:'content'}),
|
|
15
|
+
getSelection:target=>({start:target.start,end:target.end,anchor:target.end,head:target.start}),
|
|
16
|
+
cursors:{getPresence:()=>({userName:'writer'})},
|
|
17
|
+
crdt:{updateSelection:data=>positions.push(data),updateText:data=>{
|
|
18
|
+
edits.push(data);
|
|
19
|
+
element.start=1;element.end=3;
|
|
20
|
+
listeners['cocreate-crdt-update']({detail:{}});
|
|
21
|
+
const selection=data.captureSelection();
|
|
22
|
+
assert.deepEqual({...selection},{anchor:3,head:1});
|
|
23
|
+
}}
|
|
24
|
+
});
|
|
25
|
+
vm.runInContext(adapter+`\nfunction updateElements(){sendPosition(testElement);}\nglobalThis.api={updateText,sendPosition};\n_crdtUpdateListener();`,context);
|
|
26
|
+
context.testElement=element;
|
|
27
|
+
context.api.updateText({element,value:'abc',start:0});
|
|
28
|
+
context.api.sendPosition(element); // selectionchange after rendering the same edit
|
|
29
|
+
assert.equal(edits.length,1);assert.equal(positions.length,0);
|
|
30
|
+
element.start=0;context.api.sendPosition(element);
|
|
31
|
+
assert.equal(positions.length,1);
|
|
32
|
+
assert.deepEqual({...positions[0].selection},{anchor:3,head:0});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('Editor owns native selection direction and delegates rich selection to its source mapper',async()=>{
|
|
36
|
+
const source=fs.readFileSync('../CoCreate-editor/src/adapters.js','utf8');
|
|
37
|
+
const fn=source.slice(source.indexOf('export async function getSelection'),source.indexOf('export async function setSelection'))
|
|
38
|
+
.replace('export async function','async function');
|
|
39
|
+
const richSelection={anchor:4,head:1,from:1,to:4,empty:false,type:'text'};
|
|
40
|
+
const context=vm.createContext({isFrame:()=>false,
|
|
41
|
+
isNative:element=>element.tagName==='TEXTAREA',native:{has:()=>true},
|
|
42
|
+
rich:{getSelection:()=>richSelection}});
|
|
43
|
+
vm.runInContext(fn+'\nglobalThis.readSelection=getSelection;',context);
|
|
44
|
+
const input={tagName:'TEXTAREA',selectionStart:1,selectionEnd:4,selectionDirection:'backward'};
|
|
45
|
+
assert.deepEqual({...await context.readSelection(input)},richSelection);
|
|
46
|
+
assert.equal(await context.readSelection({tagName:'DIV'}),richSelection);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('Cursors consumes resolved CRDT selection and blur from the same update path',()=>{
|
|
50
|
+
const source=fs.readFileSync('../CoCreate-cursors/src/index.js','utf8').replace(/\r\n/g,'\n');
|
|
51
|
+
const listener=source.slice(source.indexOf('window.addEventListener("cocreate-crdt-selection"'),source.indexOf('socket.listen("cursor"'));
|
|
52
|
+
let receive;const drawn=[],removed=[];
|
|
53
|
+
vm.runInNewContext(listener,{
|
|
54
|
+
window:{addEventListener:(name,fn)=>receive=fn},socket:{frameId:'local'},
|
|
55
|
+
drawCursors:selection=>drawn.push(selection),removeCursor:id=>removed.push(id),
|
|
56
|
+
localStorage:{getItem:()=>null},cursorBackground:'gray'
|
|
57
|
+
});
|
|
58
|
+
receive({detail:{frameId:'remote',socketId:'peer',clientId:'browser',selection:{},
|
|
59
|
+
resolved:{anchor:4,head:1,start:1,end:4},presence:{userName:'Alice'}}});
|
|
60
|
+
assert.equal(drawn.length,1);assert.equal(drawn[0].start,1);assert.equal(drawn[0].end,4);
|
|
61
|
+
assert.equal(drawn[0].userName,'Alice');
|
|
62
|
+
receive({detail:{frameId:'remote',socketId:'peer',selection:null}});
|
|
63
|
+
assert.deepEqual(removed,['peer']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('blur clears selection and allows the same position to be shared on refocus',()=>{
|
|
67
|
+
const source=fs.readFileSync('../CoCreate-text/src/index.js','utf8').replace(/\r\n/g,'\n');
|
|
68
|
+
const blur=source.slice(source.indexOf('function _blur('),source.indexOf('function _cut('));
|
|
69
|
+
const packets=[];
|
|
70
|
+
const context=vm.createContext({previousPosition:{start:1},
|
|
71
|
+
getAttributes:()=>({array:'notes',object:'record',key:'content'}),
|
|
72
|
+
crdt:{updateSelection:data=>packets.push(data)}});
|
|
73
|
+
vm.runInContext(blur+'\nglobalThis.blur=_blur;',context);
|
|
74
|
+
context.blur({currentTarget:{getAttribute:()=> 'true'}});
|
|
75
|
+
assert.equal(packets[0].selection,null);
|
|
76
|
+
assert.equal(Object.keys(context.previousPosition).length,0);
|
|
77
|
+
});
|