MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
after three failures it falls back to polling every 3 s and retries long
polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
leases (renewal), otherwise every 30 s as a safety net. Focusing a block
announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
a stream that stays silent for 5 s (buffering proxy) switches SSE off for
the tab. App-level PING/PONG detects WebSockets that are accepted but
forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
polling, interval fallback, switching), realtime fallback tests with fake
WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
(0.2-0.3 s on all live channels, ~3 s with plain polling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import type { DocumentBlock } from '../../types';
|
|
import { DocSyncEngine } from '../engine';
|
|
import { applyOp, Doc, emptyDoc, Op, Result } from '../model';
|
|
import { MemoryStorage } from '../persist';
|
|
import { LogEntry, SyncRequest, Transport, TransportError } from '../transport';
|
|
|
|
/** Minimal stand-in server (same rules as the Go server, plus configurable leases of others). */
|
|
class Server {
|
|
doc: Doc = emptyDoc();
|
|
log: LogEntry[] = [];
|
|
results = new Map<string, Result>();
|
|
othersLeases: { blockId: string; userId: string; name: string }[] = [];
|
|
|
|
constructor(blocks: [string, string][]) {
|
|
blocks.forEach(([id, content], i) => this.apply({ opId: `seed${i}`, kind: 'insert', blockId: id, parentId: '', orderKey: `a${i}`, content }, 'seed'));
|
|
}
|
|
apply(op: Op, by: string): Result {
|
|
const known = this.results.get(op.opId);
|
|
if (known) return known;
|
|
const { effects, result } = applyOp(this.doc, op, by);
|
|
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: by, clientId: by, opId: op.opId, effect: e });
|
|
if (effects.length) result.seq = this.log.length;
|
|
this.results.set(op.opId, result);
|
|
return result;
|
|
}
|
|
transport(): Transport {
|
|
return {
|
|
snapshot: async () => ({
|
|
docId: 'd',
|
|
title: this.doc.title,
|
|
titleVersion: this.doc.titleVersion,
|
|
headSeq: this.log.length,
|
|
blocks: Array.from(this.doc.blocks.values()).map((b) => ({ ...b, props: { ...b.props } })),
|
|
role: 'owner',
|
|
shared: true,
|
|
}),
|
|
sync: async (_id: string, body: SyncRequest) => {
|
|
if (!body) throw new TransportError('x', 0);
|
|
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
|
return {
|
|
results,
|
|
ops: this.log.filter((e) => e.seq > body.lastSeq),
|
|
headSeq: this.log.length,
|
|
hasMore: false,
|
|
role: 'owner',
|
|
shared: true,
|
|
sessions: 2,
|
|
presence: [],
|
|
leases: { granted: [], denied: [], others: this.othersLeases },
|
|
};
|
|
},
|
|
leave: async () => {},
|
|
changes: async () => ({ headSeq: this.log.length, changed: false }),
|
|
};
|
|
}
|
|
}
|
|
|
|
let clock = 1_000_000;
|
|
async function engineFor(server: Server) {
|
|
const e = new DocSyncEngine('d', { transport: server.transport(), storage: new MemoryStorage(), manual: true, now: () => clock });
|
|
await e.start();
|
|
return e;
|
|
}
|
|
const flat = (e: DocSyncEngine) => e.getState().blocks;
|
|
const texts = (e: DocSyncEngine) => flat(e).map((b) => b.content);
|
|
function edit(e: DocSyncEngine, fn: (b: DocumentBlock[]) => DocumentBlock[]) {
|
|
e.applyFlat(fn(flat(e).map((b) => ({ ...b }))));
|
|
}
|
|
|
|
describe('undo/redo', () => {
|
|
it('groups typing in one block and reverts it in one step', async () => {
|
|
const server = new Server([['a', 'Hallo']]);
|
|
const e = await engineFor(server);
|
|
edit(e, (b) => [{ ...b[0], content: 'Hallo W' }]);
|
|
clock += 300;
|
|
edit(e, (b) => [{ ...b[0], content: 'Hallo Welt' }]);
|
|
expect(e.undo().ok).toBe(true);
|
|
expect(texts(e)).toEqual(['Hallo']);
|
|
expect(e.getState().canUndo).toBe(false);
|
|
expect(e.redo().ok).toBe(true);
|
|
expect(texts(e)).toEqual(['Hallo Welt']);
|
|
await e.syncNow();
|
|
expect(server.doc.blocks.get('a')!.content).toBe('Hallo Welt');
|
|
});
|
|
|
|
it('separates typing steps after a pause', async () => {
|
|
const server = new Server([['a', '']]);
|
|
const e = await engineFor(server);
|
|
edit(e, (b) => [{ ...b[0], content: 'Eins' }]);
|
|
clock += 2000;
|
|
edit(e, (b) => [{ ...b[0], content: 'Eins Zwei' }]);
|
|
e.undo();
|
|
expect(texts(e)).toEqual(['Eins']);
|
|
});
|
|
|
|
it('undoes inserting and deleting blocks', async () => {
|
|
const server = new Server([['a', 'Erster']]);
|
|
const e = await engineFor(server);
|
|
edit(e, (b) => [...b, { id: 'n', type: 'bullet', content: 'Neu', level: 0 }]);
|
|
clock += 2000;
|
|
edit(e, (b) => b.filter((x) => x.id !== 'a'));
|
|
expect(texts(e)).toEqual(['Neu']);
|
|
e.undo();
|
|
expect(texts(e)).toEqual(['Erster', 'Neu']);
|
|
e.undo();
|
|
expect(texts(e)).toEqual(['Erster']);
|
|
await e.syncNow();
|
|
expect(server.doc.blocks.get('a')!.deleted).toBe(false);
|
|
expect(server.doc.blocks.get('n')!.deleted).toBe(true);
|
|
});
|
|
|
|
it('skips undo when someone else changed the block in between', async () => {
|
|
const server = new Server([['a', 'Start']]);
|
|
const e = await engineFor(server);
|
|
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
|
await e.syncNow();
|
|
server.apply({ opId: 'other', kind: 'set_content', blockId: 'a', baseVersion: 2, content: 'Fremd' }, 'other');
|
|
await e.syncNow();
|
|
const res = e.undo();
|
|
expect(res.ok).toBe(false);
|
|
expect(res.message).toMatch(/jemand anderem/);
|
|
expect(texts(e)).toEqual(['Fremd']);
|
|
});
|
|
|
|
it('skips undo on a block someone else is editing', async () => {
|
|
const server = new Server([['a', 'Start']]);
|
|
const e = await engineFor(server);
|
|
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
|
await e.syncNow();
|
|
server.othersLeases = [{ blockId: 'a', userId: 'u2', name: 'Anna' }];
|
|
await e.syncNow();
|
|
const res = e.undo();
|
|
expect(res.ok).toBe(false);
|
|
expect(res.message).toMatch(/Anna/);
|
|
expect(texts(e)).toEqual(['Meins']);
|
|
});
|
|
});
|
|
|
|
describe('conflict copies', () => {
|
|
async function withConflict() {
|
|
const server = new Server([['a', 'Start']]);
|
|
const e = await engineFor(server);
|
|
server.apply({ opId: 'first', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Gewinner' }, 'other');
|
|
server.apply({ opId: 'second', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Verlierer' }, 'mine');
|
|
await e.syncNow();
|
|
const copy = flat(e).find((b) => b.conflictOf === 'a')!;
|
|
return { server, e, copy };
|
|
}
|
|
|
|
it('accepting replaces the original text and removes the copy', async () => {
|
|
const { server, e, copy } = await withConflict();
|
|
expect(e.resolveConflict(copy.id, true).ok).toBe(true);
|
|
expect(texts(e)).toEqual(['Verlierer']);
|
|
await e.syncNow();
|
|
expect(server.doc.blocks.get('a')!.content).toBe('Verlierer');
|
|
expect(server.doc.blocks.get(copy.id)!.deleted).toBe(true);
|
|
e.undo();
|
|
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
|
});
|
|
|
|
it('is not started while someone else holds the copy', async () => {
|
|
const { server, e, copy } = await withConflict();
|
|
server.othersLeases = [{ blockId: copy.id, userId: 'u2', name: 'Ben' }];
|
|
await e.syncNow();
|
|
const res = e.resolveConflict(copy.id, true);
|
|
expect(res.ok).toBe(false);
|
|
expect(res.message).toMatch(/Ben/);
|
|
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
|
});
|
|
|
|
it('discarding removes only the copy', async () => {
|
|
const { e, copy } = await withConflict();
|
|
e.resolveConflict(copy.id, false);
|
|
expect(texts(e)).toEqual(['Gewinner']);
|
|
});
|
|
|
|
it('a copy whose original is gone becomes a normal block', async () => {
|
|
const { server, e, copy } = await withConflict();
|
|
server.apply({ opId: 'del', kind: 'delete', blockId: 'a' }, 'other');
|
|
await e.syncNow();
|
|
e.resolveConflict(copy.id, true);
|
|
expect(flat(e).find((b) => b.id === copy.id)?.conflictOf).toBeUndefined();
|
|
});
|
|
});
|