@1agh/maude 0.20.0 → 0.21.0
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/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +16 -8
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
- package/plugins/design/dev-server/annotations-layer.tsx +52 -12
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +33 -7
- package/plugins/design/dev-server/canvas-shell.tsx +127 -9
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/comments-overlay.tsx +29 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +64 -1
- package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +8 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +35 -1
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Unit: Task 5 — Registry.syncRoomFromAnnotations bridges PUT /_api/annotations
|
|
2
|
+
// writes into the live Y.Map so collab peers see the SVG without cold-open.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
7
|
+
import { createRegistry } from '../collab/registry.ts';
|
|
8
|
+
import type { RoomCallbacks } from '../collab/room.ts';
|
|
9
|
+
|
|
10
|
+
function noopCallbacks(): RoomCallbacks {
|
|
11
|
+
return {
|
|
12
|
+
async seed() {},
|
|
13
|
+
async persistJson() {},
|
|
14
|
+
async persistBinary() {},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('Registry.syncRoomFromAnnotations', () => {
|
|
19
|
+
test('populates Y.Map.svg', () => {
|
|
20
|
+
const r = createRegistry(noopCallbacks());
|
|
21
|
+
const room = r.get('canvas-slug');
|
|
22
|
+
const map = room.doc.getMap<string>(Y_TYPES.annotations);
|
|
23
|
+
expect(map.get('svg')).toBeUndefined();
|
|
24
|
+
|
|
25
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><g/></svg>';
|
|
26
|
+
r.syncRoomFromAnnotations('canvas-slug', svg);
|
|
27
|
+
expect(map.get('svg')).toBe(svg);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('replaces existing svg on each call (LWW)', () => {
|
|
31
|
+
const r = createRegistry(noopCallbacks());
|
|
32
|
+
const room = r.get('canvas-slug');
|
|
33
|
+
r.syncRoomFromAnnotations('canvas-slug', '<svg>1</svg>');
|
|
34
|
+
r.syncRoomFromAnnotations('canvas-slug', '<svg>2</svg>');
|
|
35
|
+
expect(room.doc.getMap(Y_TYPES.annotations).get('svg')).toBe('<svg>2</svg>');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('no-op when no room exists for slug', () => {
|
|
39
|
+
const r = createRegistry(noopCallbacks());
|
|
40
|
+
r.syncRoomFromAnnotations('absent', '<svg/>');
|
|
41
|
+
expect(r.peek('absent')).toBeNull();
|
|
42
|
+
expect(r.size()).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('inspector-write origin propagates through doc.update', () => {
|
|
46
|
+
const r = createRegistry(noopCallbacks());
|
|
47
|
+
const room = r.get('echo-slug');
|
|
48
|
+
let lastOrigin: unknown = undefined;
|
|
49
|
+
room.doc.on('update', (_update, origin) => {
|
|
50
|
+
lastOrigin = origin;
|
|
51
|
+
});
|
|
52
|
+
r.syncRoomFromAnnotations('echo-slug', '<svg/>');
|
|
53
|
+
expect(lastOrigin).toBe('inspector-write');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Unit: Registry inspector-bridge — REST/WS writes to api.comments* must
|
|
2
|
+
// reach the live Y.Array so collab peers see the change without waiting for
|
|
3
|
+
// a cold-open re-seed.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import * as Y from 'yjs';
|
|
8
|
+
|
|
9
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
10
|
+
import { createRegistry } from '../collab/registry.ts';
|
|
11
|
+
import type { RoomCallbacks } from '../collab/room.ts';
|
|
12
|
+
|
|
13
|
+
function noopCallbacks(): RoomCallbacks {
|
|
14
|
+
return {
|
|
15
|
+
async seed() {},
|
|
16
|
+
async persistJson() {},
|
|
17
|
+
async persistBinary() {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Registry inspector-bridge', () => {
|
|
22
|
+
test('peek returns null when no room is live', () => {
|
|
23
|
+
const r = createRegistry(noopCallbacks());
|
|
24
|
+
expect(r.peek('absent')).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('peek returns the room without creating one', () => {
|
|
28
|
+
const r = createRegistry(noopCallbacks());
|
|
29
|
+
expect(r.size()).toBe(0);
|
|
30
|
+
expect(r.peek('foo')).toBeNull();
|
|
31
|
+
expect(r.size()).toBe(0);
|
|
32
|
+
r.get('foo'); // create
|
|
33
|
+
expect(r.size()).toBe(1);
|
|
34
|
+
expect(r.peek('foo')).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('syncRoomFromComments populates the live Y.Array', () => {
|
|
38
|
+
const r = createRegistry(noopCallbacks());
|
|
39
|
+
const room = r.get('canvas-slug');
|
|
40
|
+
expect(room.doc.getArray(Y_TYPES.comments).length).toBe(0);
|
|
41
|
+
|
|
42
|
+
r.syncRoomFromComments('canvas-slug', [
|
|
43
|
+
{ id: 'c1', text: 'first', status: 'open' },
|
|
44
|
+
{ id: 'c2', text: 'second', status: 'open' },
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const arr = room.doc.getArray(Y_TYPES.comments);
|
|
48
|
+
expect(arr.length).toBe(2);
|
|
49
|
+
expect((arr.get(0) as { id: string }).id).toBe('c1');
|
|
50
|
+
expect((arr.get(1) as { id: string }).id).toBe('c2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('syncRoomFromComments replaces existing contents (idempotent)', () => {
|
|
54
|
+
const r = createRegistry(noopCallbacks());
|
|
55
|
+
const room = r.get('canvas-slug');
|
|
56
|
+
r.syncRoomFromComments('canvas-slug', [{ id: 'c1' }, { id: 'c2' }]);
|
|
57
|
+
r.syncRoomFromComments('canvas-slug', [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]);
|
|
58
|
+
expect(room.doc.getArray(Y_TYPES.comments).length).toBe(3);
|
|
59
|
+
r.syncRoomFromComments('canvas-slug', []);
|
|
60
|
+
expect(room.doc.getArray(Y_TYPES.comments).length).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('syncRoomFromComments is a no-op for slugs with no room', () => {
|
|
64
|
+
const r = createRegistry(noopCallbacks());
|
|
65
|
+
// Must not throw, must not create a room.
|
|
66
|
+
r.syncRoomFromComments('absent', [{ id: 'x' }]);
|
|
67
|
+
expect(r.peek('absent')).toBeNull();
|
|
68
|
+
expect(r.size()).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('inspector-write origin triggers doc.update broadcast', async () => {
|
|
72
|
+
const r = createRegistry(noopCallbacks());
|
|
73
|
+
const room = r.get('echo-slug');
|
|
74
|
+
let lastOrigin: unknown = undefined;
|
|
75
|
+
room.doc.on('update', (_update, origin) => {
|
|
76
|
+
lastOrigin = origin;
|
|
77
|
+
});
|
|
78
|
+
r.syncRoomFromComments('echo-slug', [{ id: 'c1' }]);
|
|
79
|
+
expect(lastOrigin).toBe('inspector-write');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Unit: helpers exported by ws.ts that gate the Phase 8 collab endpoint.
|
|
2
|
+
// Pure functions — no Bun.serve / no fixtures needed. The full integration
|
|
3
|
+
// (host-header rejection at the upgrade layer) is covered by the manual
|
|
4
|
+
// curl smoke in the Task 1 validate row.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { isLoopbackHost, parseCollabSlug } from '../ws.ts';
|
|
9
|
+
|
|
10
|
+
describe('parseCollabSlug', () => {
|
|
11
|
+
test('extracts slug from /_ws/collab/<slug>', () => {
|
|
12
|
+
expect(parseCollabSlug('/_ws/collab/foo')).toBe('foo');
|
|
13
|
+
expect(parseCollabSlug('/_ws/collab/ui-home_screen')).toBe('ui-home_screen');
|
|
14
|
+
expect(parseCollabSlug('/_ws/collab/A1B2c3')).toBe('A1B2c3');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('rejects non-collab paths', () => {
|
|
18
|
+
expect(parseCollabSlug('/_ws')).toBeNull();
|
|
19
|
+
expect(parseCollabSlug('/_ws/inspector')).toBeNull();
|
|
20
|
+
expect(parseCollabSlug('/_ws/collab')).toBeNull();
|
|
21
|
+
expect(parseCollabSlug('/_ws/collab/')).toBeNull();
|
|
22
|
+
expect(parseCollabSlug('/api/comments')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('rejects slugs with disallowed chars (URL-encoded, slashes, dots)', () => {
|
|
26
|
+
expect(parseCollabSlug('/_ws/collab/foo/bar')).toBeNull();
|
|
27
|
+
expect(parseCollabSlug('/_ws/collab/foo.bar')).toBeNull();
|
|
28
|
+
expect(parseCollabSlug('/_ws/collab/foo%20bar')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('isLoopbackHost', () => {
|
|
33
|
+
test('accepts loopback aliases with or without port', () => {
|
|
34
|
+
expect(isLoopbackHost('127.0.0.1:4399')).toBe(true);
|
|
35
|
+
expect(isLoopbackHost('127.0.0.1')).toBe(true);
|
|
36
|
+
expect(isLoopbackHost('localhost:4399')).toBe(true);
|
|
37
|
+
expect(isLoopbackHost('localhost')).toBe(true);
|
|
38
|
+
expect(isLoopbackHost('[::1]:4399')).toBe(true);
|
|
39
|
+
expect(isLoopbackHost('[::1]')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('case-insensitive', () => {
|
|
43
|
+
expect(isLoopbackHost('LOCALHOST:4399')).toBe(true);
|
|
44
|
+
expect(isLoopbackHost('LocalHost')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('rejects non-loopback hosts', () => {
|
|
48
|
+
expect(isLoopbackHost('example.com')).toBe(false);
|
|
49
|
+
expect(isLoopbackHost('example.com:4399')).toBe(false);
|
|
50
|
+
expect(isLoopbackHost('10.0.0.1:4399')).toBe(false);
|
|
51
|
+
expect(isLoopbackHost('192.168.1.1')).toBe(false);
|
|
52
|
+
expect(isLoopbackHost('host.docker.internal')).toBe(false);
|
|
53
|
+
// Common attacker patterns — host header smuggling tries.
|
|
54
|
+
expect(isLoopbackHost('127.0.0.1.attacker.com')).toBe(false);
|
|
55
|
+
expect(isLoopbackHost('localhost.attacker.com')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('handles missing / empty host gracefully', () => {
|
|
59
|
+
expect(isLoopbackHost(null)).toBe(false);
|
|
60
|
+
expect(isLoopbackHost('')).toBe(false);
|
|
61
|
+
expect(isLoopbackHost(' ')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Unit: y-websocket binary protocol round-trips. Validates the frames the
|
|
2
|
+
// dev-server emits actually decode against a vanilla yjs/y-protocols peer.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import * as decoding from 'lib0/decoding';
|
|
7
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
8
|
+
import * as Y from 'yjs';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
MESSAGE_AWARENESS,
|
|
12
|
+
MESSAGE_SYNC,
|
|
13
|
+
encodeAwarenessFrame,
|
|
14
|
+
encodeHandshake,
|
|
15
|
+
encodeSyncUpdate,
|
|
16
|
+
handleMessage,
|
|
17
|
+
} from '../collab/protocol.ts';
|
|
18
|
+
|
|
19
|
+
function readType(frame: Uint8Array): number {
|
|
20
|
+
return decoding.readVarUint(decoding.createDecoder(frame));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('encodeHandshake', () => {
|
|
24
|
+
test('always emits sync step 1 frame', () => {
|
|
25
|
+
const doc = new Y.Doc();
|
|
26
|
+
const aw = new Awareness(doc);
|
|
27
|
+
const frames = encodeHandshake(doc, aw);
|
|
28
|
+
expect(frames.length).toBeGreaterThanOrEqual(1);
|
|
29
|
+
// biome-ignore lint/style/noNonNullAssertion: length asserted above
|
|
30
|
+
expect(readType(frames[0]!)).toBe(MESSAGE_SYNC);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('appends awareness frame when awareness has state', () => {
|
|
34
|
+
const doc = new Y.Doc();
|
|
35
|
+
const aw = new Awareness(doc);
|
|
36
|
+
aw.setLocalState({ name: 'Alice', __connId: 'a' });
|
|
37
|
+
const frames = encodeHandshake(doc, aw);
|
|
38
|
+
expect(frames.length).toBe(2);
|
|
39
|
+
// biome-ignore lint/style/noNonNullAssertion: length asserted above
|
|
40
|
+
expect(readType(frames[0]!)).toBe(MESSAGE_SYNC);
|
|
41
|
+
// biome-ignore lint/style/noNonNullAssertion: length asserted above
|
|
42
|
+
expect(readType(frames[1]!)).toBe(MESSAGE_AWARENESS);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('omits awareness frame when awareness is empty', () => {
|
|
46
|
+
const doc = new Y.Doc();
|
|
47
|
+
const aw = new Awareness(doc);
|
|
48
|
+
aw.setLocalState(null); // clear default
|
|
49
|
+
const frames = encodeHandshake(doc, aw);
|
|
50
|
+
expect(frames.length).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('encodeSyncUpdate', () => {
|
|
55
|
+
test('wraps a Y.Doc update bytes in a sync frame', () => {
|
|
56
|
+
const doc = new Y.Doc();
|
|
57
|
+
doc.getArray('comments').push([{ id: 'c1', text: 'hi' }]);
|
|
58
|
+
const update = Y.encodeStateAsUpdate(doc);
|
|
59
|
+
const frame = encodeSyncUpdate(update);
|
|
60
|
+
expect(readType(frame)).toBe(MESSAGE_SYNC);
|
|
61
|
+
// The wrapped update applied to a fresh doc should reproduce the state.
|
|
62
|
+
const replay = new Y.Doc();
|
|
63
|
+
// Strip the message-type byte and the sync-step opcode by re-running
|
|
64
|
+
// through handleMessage — that's the contract clients use too.
|
|
65
|
+
const aw = new Awareness(replay);
|
|
66
|
+
handleMessage(frame, replay, aw, { send() {} });
|
|
67
|
+
expect((replay.getArray('comments').get(0) as { text: string }).text).toBe('hi');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('encodeAwarenessFrame', () => {
|
|
72
|
+
test('emits awareness type byte + payload that applies to another Awareness', () => {
|
|
73
|
+
const docA = new Y.Doc();
|
|
74
|
+
const awA = new Awareness(docA);
|
|
75
|
+
awA.setLocalState({ name: 'Alice', __connId: 'a' });
|
|
76
|
+
const frame = encodeAwarenessFrame(awA, [awA.clientID]);
|
|
77
|
+
expect(readType(frame)).toBe(MESSAGE_AWARENESS);
|
|
78
|
+
|
|
79
|
+
const docB = new Y.Doc();
|
|
80
|
+
const awB = new Awareness(docB);
|
|
81
|
+
// Drop B's default local state so we can assert exactly one foreign peer
|
|
82
|
+
// landed in B's awareness registry.
|
|
83
|
+
awB.setLocalState(null);
|
|
84
|
+
handleMessage(frame, docB, awB, { send() {} });
|
|
85
|
+
const alice = awB.getStates().get(awA.clientID) as
|
|
86
|
+
| { name: string; __connId: string }
|
|
87
|
+
| undefined;
|
|
88
|
+
expect(alice?.name).toBe('Alice');
|
|
89
|
+
expect(alice?.__connId).toBe('a');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('handleMessage', () => {
|
|
94
|
+
test('full bidirectional handshake converges two peers', () => {
|
|
95
|
+
// Peer A: has comments
|
|
96
|
+
const docA = new Y.Doc();
|
|
97
|
+
docA.getArray('comments').push([{ id: 'c1', text: 'A wrote this' }]);
|
|
98
|
+
const awA = new Awareness(docA);
|
|
99
|
+
|
|
100
|
+
// Peer B: empty
|
|
101
|
+
const docB = new Y.Doc();
|
|
102
|
+
const awB = new Awareness(docB);
|
|
103
|
+
|
|
104
|
+
// Full handshake = each peer sends its own sync step 1, the other responds
|
|
105
|
+
// with sync step 2 (the diff). y-websocket's reference impl does the same.
|
|
106
|
+
const aFrames = encodeHandshake(docA, awA);
|
|
107
|
+
const bFrames = encodeHandshake(docB, awB);
|
|
108
|
+
|
|
109
|
+
// Cross-feed.
|
|
110
|
+
for (const frame of bFrames) {
|
|
111
|
+
const reply = handleMessage(frame, docA, awA, { send() {} });
|
|
112
|
+
if (reply) handleMessage(reply, docB, awB, { send() {} });
|
|
113
|
+
}
|
|
114
|
+
for (const frame of aFrames) {
|
|
115
|
+
const reply = handleMessage(frame, docB, awB, { send() {} });
|
|
116
|
+
if (reply) handleMessage(reply, docA, awA, { send() {} });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// After exchange B has A's comment.
|
|
120
|
+
expect(docB.getArray('comments').length).toBe(1);
|
|
121
|
+
const c = docB.getArray('comments').get(0) as { text: string };
|
|
122
|
+
expect(c.text).toBe('A wrote this');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('post-handshake encodeSyncUpdate propagates a single edit', () => {
|
|
126
|
+
const docA = new Y.Doc();
|
|
127
|
+
const docB = new Y.Doc();
|
|
128
|
+
// A makes an edit and emits the diff via encodeSyncUpdate.
|
|
129
|
+
docA.getArray('comments').push([{ id: 'c2', text: 'later edit' }]);
|
|
130
|
+
const frame = encodeSyncUpdate(Y.encodeStateAsUpdate(docA));
|
|
131
|
+
|
|
132
|
+
const awB = new Awareness(docB);
|
|
133
|
+
handleMessage(frame, docB, awB, { send() {} });
|
|
134
|
+
expect(docB.getArray('comments').length).toBe(1);
|
|
135
|
+
expect((docB.getArray('comments').get(0) as { text: string }).text).toBe('later edit');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('unknown message type is a noop (forward-compat)', () => {
|
|
139
|
+
const doc = new Y.Doc();
|
|
140
|
+
const aw = new Awareness(doc);
|
|
141
|
+
// Frame with message type 99 (unknown).
|
|
142
|
+
const frame = new Uint8Array([99]);
|
|
143
|
+
const reply = handleMessage(frame, doc, aw, { send() {} });
|
|
144
|
+
expect(reply).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Unit: Room behavior — two in-memory peers see each other, debounced flush
|
|
2
|
+
// fires, flush is idempotent, room teardown is clean.
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import * as Y from 'yjs';
|
|
7
|
+
|
|
8
|
+
import { encodeHandshake, encodeSyncUpdate, handleMessage } from '../collab/protocol.ts';
|
|
9
|
+
import { type RoomCallbacks, type RoomConn, createRoom } from '../collab/room.ts';
|
|
10
|
+
|
|
11
|
+
function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
|
|
12
|
+
const recv: Uint8Array[] = [];
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
send(payload: Uint8Array) {
|
|
16
|
+
recv.push(payload);
|
|
17
|
+
},
|
|
18
|
+
recv,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeCallbacks(): RoomCallbacks & {
|
|
23
|
+
jsonCalls: number;
|
|
24
|
+
binaryCalls: number;
|
|
25
|
+
lastJson: { slug: string; commentsLen: number } | null;
|
|
26
|
+
} {
|
|
27
|
+
const cb = {
|
|
28
|
+
jsonCalls: 0,
|
|
29
|
+
binaryCalls: 0,
|
|
30
|
+
lastJson: null as { slug: string; commentsLen: number } | null,
|
|
31
|
+
async seed(_slug: string, _doc: Y.Doc) {
|
|
32
|
+
/* empty */
|
|
33
|
+
},
|
|
34
|
+
async persistJson(slug: string, doc: Y.Doc) {
|
|
35
|
+
cb.jsonCalls++;
|
|
36
|
+
cb.lastJson = { slug, commentsLen: doc.getArray('comments').length };
|
|
37
|
+
},
|
|
38
|
+
async persistBinary(_slug: string, _state: Uint8Array) {
|
|
39
|
+
cb.binaryCalls++;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return cb;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEBOUNCE_MS = 800;
|
|
46
|
+
const SETTLE_MS = DEBOUNCE_MS + 100;
|
|
47
|
+
|
|
48
|
+
describe('Room', () => {
|
|
49
|
+
test('two peers converge on Y.Doc updates', async () => {
|
|
50
|
+
const cb = makeCallbacks();
|
|
51
|
+
const room = createRoom('test-slug', cb);
|
|
52
|
+
const A = makeConn('a');
|
|
53
|
+
const B = makeConn('b');
|
|
54
|
+
|
|
55
|
+
await room.connect(A);
|
|
56
|
+
await room.connect(B);
|
|
57
|
+
|
|
58
|
+
// Drain handshake frames each peer received.
|
|
59
|
+
A.recv.length = 0;
|
|
60
|
+
B.recv.length = 0;
|
|
61
|
+
|
|
62
|
+
// Peer A produces an update; route it through the room (as the protocol
|
|
63
|
+
// would). Easiest path: mutate doc directly through a transaction tagged
|
|
64
|
+
// with A.
|
|
65
|
+
room.doc.transact(() => {
|
|
66
|
+
room.doc.getArray('comments').push([{ id: 'c1', text: 'hello' }]);
|
|
67
|
+
}, A);
|
|
68
|
+
|
|
69
|
+
// Room broadcasts the encoded update to all peers except A.
|
|
70
|
+
expect(A.recv.length).toBe(0);
|
|
71
|
+
expect(B.recv.length).toBe(1);
|
|
72
|
+
|
|
73
|
+
// B replays the frame against its own doc; the comment lands.
|
|
74
|
+
const docB = new Y.Doc();
|
|
75
|
+
const awBStub = (await import('y-protocols/awareness')).Awareness;
|
|
76
|
+
const awB = new awBStub(docB);
|
|
77
|
+
// biome-ignore lint/style/noNonNullAssertion: recv.length asserted in the prior expect()
|
|
78
|
+
handleMessage(B.recv[0]!, docB, awB, { send() {} });
|
|
79
|
+
expect(docB.getArray('comments').length).toBe(1);
|
|
80
|
+
|
|
81
|
+
await room.destroy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('debounced flush fires after 800ms of quiescence', async () => {
|
|
85
|
+
const cb = makeCallbacks();
|
|
86
|
+
const room = createRoom('debounce-slug', cb);
|
|
87
|
+
const A = makeConn('a');
|
|
88
|
+
await room.connect(A);
|
|
89
|
+
|
|
90
|
+
room.doc.transact(() => {
|
|
91
|
+
room.doc.getArray('comments').push([{ id: 'c1', text: 'one' }]);
|
|
92
|
+
}, A);
|
|
93
|
+
expect(cb.jsonCalls).toBe(0); // not yet — still inside debounce window
|
|
94
|
+
|
|
95
|
+
await Bun.sleep(SETTLE_MS);
|
|
96
|
+
expect(cb.jsonCalls).toBe(1);
|
|
97
|
+
expect(cb.binaryCalls).toBe(1);
|
|
98
|
+
expect(cb.lastJson?.commentsLen).toBe(1);
|
|
99
|
+
|
|
100
|
+
await room.destroy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('rapid updates collapse to a single flush at quiescence', async () => {
|
|
104
|
+
const cb = makeCallbacks();
|
|
105
|
+
const room = createRoom('coalesce-slug', cb);
|
|
106
|
+
const A = makeConn('a');
|
|
107
|
+
await room.connect(A);
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < 5; i++) {
|
|
110
|
+
room.doc.transact(() => {
|
|
111
|
+
room.doc.getArray('comments').push([{ id: `c${i}`, text: `n${i}` }]);
|
|
112
|
+
}, A);
|
|
113
|
+
}
|
|
114
|
+
expect(cb.jsonCalls).toBe(0);
|
|
115
|
+
await Bun.sleep(SETTLE_MS);
|
|
116
|
+
expect(cb.jsonCalls).toBe(1);
|
|
117
|
+
expect(cb.lastJson?.commentsLen).toBe(5);
|
|
118
|
+
|
|
119
|
+
await room.destroy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('explicit flush() bypasses the debounce', async () => {
|
|
123
|
+
const cb = makeCallbacks();
|
|
124
|
+
const room = createRoom('force-flush-slug', cb);
|
|
125
|
+
const A = makeConn('a');
|
|
126
|
+
await room.connect(A);
|
|
127
|
+
|
|
128
|
+
room.doc.transact(() => {
|
|
129
|
+
room.doc.getArray('comments').push([{ id: 'c1', text: 'one' }]);
|
|
130
|
+
}, A);
|
|
131
|
+
expect(cb.jsonCalls).toBe(0);
|
|
132
|
+
|
|
133
|
+
await room.flush();
|
|
134
|
+
expect(cb.jsonCalls).toBe(1);
|
|
135
|
+
|
|
136
|
+
// Idempotent — second flush with no further mutations doesn't write again.
|
|
137
|
+
await room.flush();
|
|
138
|
+
expect(cb.jsonCalls).toBe(1);
|
|
139
|
+
|
|
140
|
+
await room.destroy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('disconnect cleans up awareness states tagged with __connId', async () => {
|
|
144
|
+
const cb = makeCallbacks();
|
|
145
|
+
const room = createRoom('aw-cleanup-slug', cb);
|
|
146
|
+
const A = makeConn('a');
|
|
147
|
+
await room.connect(A);
|
|
148
|
+
|
|
149
|
+
// Simulate Alice publishing her awareness state with the matching __connId.
|
|
150
|
+
room.awareness.setLocalState({ name: 'Alice', __connId: 'a' });
|
|
151
|
+
expect(room.awareness.getStates().size).toBe(1);
|
|
152
|
+
|
|
153
|
+
room.disconnect(A);
|
|
154
|
+
expect(room.size()).toBe(0);
|
|
155
|
+
// Awareness state owned by the disconnected conn is gone.
|
|
156
|
+
const states = room.awareness.getStates();
|
|
157
|
+
for (const s of states.values()) {
|
|
158
|
+
expect((s as { __connId?: string }).__connId).not.toBe('a');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await room.destroy();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('seed is called once even with concurrent connects', async () => {
|
|
165
|
+
let seedCalls = 0;
|
|
166
|
+
const cb: RoomCallbacks = {
|
|
167
|
+
async seed(_slug, _doc) {
|
|
168
|
+
seedCalls++;
|
|
169
|
+
await Bun.sleep(10);
|
|
170
|
+
},
|
|
171
|
+
async persistJson() {},
|
|
172
|
+
async persistBinary() {},
|
|
173
|
+
};
|
|
174
|
+
const room = createRoom('seed-once-slug', cb);
|
|
175
|
+
const A = makeConn('a');
|
|
176
|
+
const B = makeConn('b');
|
|
177
|
+
await Promise.all([room.connect(A), room.connect(B)]);
|
|
178
|
+
expect(seedCalls).toBe(1);
|
|
179
|
+
|
|
180
|
+
await room.destroy();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Phase 8 Task 8 — multi-tab stress harness.
|
|
2
|
+
//
|
|
3
|
+
// Two in-memory peers attached to one Room, broadcasting cursor-shaped
|
|
4
|
+
// Awareness updates at ~30 Hz for `STRESS_MS` (default 10 s in CI; the plan
|
|
5
|
+
// spec calls for 2 min but a tighter CI default keeps the run under 15 s).
|
|
6
|
+
// Measures:
|
|
7
|
+
//
|
|
8
|
+
// - RSS growth via process.memoryUsage().rss (before vs after)
|
|
9
|
+
// - Y.Doc state size growth via Y.encodeStateAsUpdate(doc).byteLength
|
|
10
|
+
// - Awareness state cleanup — when both peers disconnect, no entries leak
|
|
11
|
+
//
|
|
12
|
+
// Pass thresholds (CI-safe):
|
|
13
|
+
// - RSS delta < 20 MB (plan spec; conservative because GC is non-deterministic)
|
|
14
|
+
// - Y.Doc growth < 100 KB (the spec ceiling is 500 KB for a 2-min run; with
|
|
15
|
+
// awareness ephemeral + no doc.update traffic, growth should be ~0).
|
|
16
|
+
|
|
17
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
20
|
+
import * as Y from 'yjs';
|
|
21
|
+
|
|
22
|
+
import { type RoomConn, createRoom } from '../collab/room.ts';
|
|
23
|
+
|
|
24
|
+
const STRESS_MS = Number(process.env.MAUDE_STRESS_MS ?? 10_000);
|
|
25
|
+
const RSS_GROWTH_LIMIT_MB = 20;
|
|
26
|
+
const YDOC_GROWTH_LIMIT_BYTES = 100_000;
|
|
27
|
+
|
|
28
|
+
function makeConn(id: string): RoomConn {
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
send() {
|
|
32
|
+
/* drain — we don't care about the network bytes; the memory pressure
|
|
33
|
+
is on the Awareness map + Y.Doc internals, not the WS buffer */
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('multi-tab stress (Phase 8 Task 8)', () => {
|
|
39
|
+
let cleanupRoom: (() => Promise<void>) | null = null;
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
if (cleanupRoom) {
|
|
43
|
+
await cleanupRoom();
|
|
44
|
+
cleanupRoom = null;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test(
|
|
49
|
+
'30 Hz × STRESS_MS × 2 peers — bounded RSS + Y.Doc growth',
|
|
50
|
+
async () => {
|
|
51
|
+
const room = createRoom('stress-slug', {
|
|
52
|
+
async seed() {},
|
|
53
|
+
async persistJson() {},
|
|
54
|
+
async persistBinary() {},
|
|
55
|
+
});
|
|
56
|
+
cleanupRoom = () => room.destroy();
|
|
57
|
+
|
|
58
|
+
const A = makeConn('peer-a');
|
|
59
|
+
const B = makeConn('peer-b');
|
|
60
|
+
await room.connect(A);
|
|
61
|
+
await room.connect(B);
|
|
62
|
+
|
|
63
|
+
// Each peer has its own local Awareness instance to model what real
|
|
64
|
+
// clients do — they don't share a memory address space with the server's
|
|
65
|
+
// awareness. We pump updates by setting state on the server-side
|
|
66
|
+
// awareness directly (the server's room.awareness is what the real WS
|
|
67
|
+
// peers' setLocalState() would land in via applyAwarenessUpdate).
|
|
68
|
+
const awA = new Awareness(new Y.Doc());
|
|
69
|
+
const awB = new Awareness(new Y.Doc());
|
|
70
|
+
awA.setLocalState({ name: 'Alice', color: '#f00', cursor: { x: 0, y: 0 }, __connId: A.id });
|
|
71
|
+
awB.setLocalState({ name: 'Bob', color: '#0f0', cursor: { x: 0, y: 0 }, __connId: B.id });
|
|
72
|
+
|
|
73
|
+
const rssBefore = process.memoryUsage().rss;
|
|
74
|
+
const docSizeBefore = Y.encodeStateAsUpdate(room.doc).byteLength;
|
|
75
|
+
|
|
76
|
+
const start = Date.now();
|
|
77
|
+
const tickInterval = 33; // ~30 Hz
|
|
78
|
+
let aX = 0;
|
|
79
|
+
let bX = 0;
|
|
80
|
+
let updates = 0;
|
|
81
|
+
while (Date.now() - start < STRESS_MS) {
|
|
82
|
+
aX = (aX + 1) % 1000;
|
|
83
|
+
bX = (bX + 7) % 1000;
|
|
84
|
+
awA.setLocalState({
|
|
85
|
+
name: 'Alice',
|
|
86
|
+
color: '#f00',
|
|
87
|
+
cursor: { x: aX, y: aX },
|
|
88
|
+
__connId: A.id,
|
|
89
|
+
});
|
|
90
|
+
awB.setLocalState({
|
|
91
|
+
name: 'Bob',
|
|
92
|
+
color: '#0f0',
|
|
93
|
+
cursor: { x: bX, y: bX },
|
|
94
|
+
__connId: B.id,
|
|
95
|
+
});
|
|
96
|
+
updates += 2;
|
|
97
|
+
await Bun.sleep(tickInterval);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rssAfter = process.memoryUsage().rss;
|
|
101
|
+
const docSizeAfter = Y.encodeStateAsUpdate(room.doc).byteLength;
|
|
102
|
+
|
|
103
|
+
const rssGrowthMb = (rssAfter - rssBefore) / (1024 * 1024);
|
|
104
|
+
const docGrowth = docSizeAfter - docSizeBefore;
|
|
105
|
+
|
|
106
|
+
console.log(
|
|
107
|
+
`[stress] ${updates} awareness updates over ${STRESS_MS} ms → ` +
|
|
108
|
+
`RSS Δ=${rssGrowthMb.toFixed(1)} MB, Y.Doc Δ=${docGrowth} bytes`
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(rssGrowthMb).toBeLessThan(RSS_GROWTH_LIMIT_MB);
|
|
112
|
+
expect(docGrowth).toBeLessThan(YDOC_GROWTH_LIMIT_BYTES);
|
|
113
|
+
|
|
114
|
+
// Disconnect both peers; size should go to 0.
|
|
115
|
+
room.disconnect(A);
|
|
116
|
+
room.disconnect(B);
|
|
117
|
+
expect(room.size()).toBe(0);
|
|
118
|
+
},
|
|
119
|
+
STRESS_MS + 5_000
|
|
120
|
+
);
|
|
121
|
+
});
|