@1agh/maude 0.19.1 → 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.
Files changed (105) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +17 -9
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -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
+ });
@@ -0,0 +1,93 @@
1
+ // equal-spacing-detector — T27 (Wave 3). Pure detector fixtures.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { detectEqualSpacing } from '../equal-spacing-detector.ts';
6
+ import type { Rect } from '../use-snap-guides.tsx';
7
+
8
+ const r = (x: number, y: number, w = 100, h = 60): Rect => ({ x, y, w, h });
9
+
10
+ describe('detectEqualSpacing / preconditions', () => {
11
+ test('fewer than 3 rects → null', () => {
12
+ expect(detectEqualSpacing([], 'x')).toBeNull();
13
+ expect(detectEqualSpacing([r(0, 0)], 'x')).toBeNull();
14
+ expect(detectEqualSpacing([r(0, 0), r(200, 0)], 'x')).toBeNull();
15
+ });
16
+
17
+ test('overlapping rects → null (gap < 0)', () => {
18
+ // r1 spans 0..100, r2 starts at 90 (overlap by 10)
19
+ expect(detectEqualSpacing([r(0, 0), r(90, 0), r(220, 0)], 'x')).toBeNull();
20
+ });
21
+ });
22
+
23
+ describe('detectEqualSpacing / horizontal axis', () => {
24
+ test('3 rects, equal gap 20 → detects with gap=20 + 2 midpoints', () => {
25
+ // 0..100, 120..220, 240..340 — gap = 20
26
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(240, 0)], 'x');
27
+ expect(out).not.toBeNull();
28
+ expect(out?.axis).toBe('x');
29
+ expect(out?.gapPx).toBe(20);
30
+ expect(out?.midpoints).toHaveLength(2);
31
+ expect(out?.midpoints[0]).toEqual({ x: 110, y: 30 });
32
+ expect(out?.midpoints[1]).toEqual({ x: 230, y: 30 });
33
+ });
34
+
35
+ test('uneven gaps outside tolerance → null', () => {
36
+ // gaps: 20, 30
37
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(250, 0)], 'x');
38
+ expect(out).toBeNull();
39
+ });
40
+
41
+ test('within 1 px tolerance is accepted', () => {
42
+ // gaps: 20, 21 — within default tolerance of 1
43
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(241, 0)], 'x');
44
+ expect(out).not.toBeNull();
45
+ expect(out?.gapPx).toBeGreaterThanOrEqual(20);
46
+ expect(out?.gapPx).toBeLessThanOrEqual(21);
47
+ });
48
+
49
+ test('explicit tolerance allows wider band', () => {
50
+ // gaps: 20, 25
51
+ const tightFail = detectEqualSpacing([r(0, 0), r(120, 0), r(245, 0)], 'x');
52
+ expect(tightFail).toBeNull();
53
+ const loosePass = detectEqualSpacing([r(0, 0), r(120, 0), r(245, 0)], 'x', {
54
+ tolerancePx: 5,
55
+ });
56
+ expect(loosePass).not.toBeNull();
57
+ });
58
+
59
+ test('rects passed in random order → sorted internally', () => {
60
+ const out = detectEqualSpacing([r(240, 0), r(0, 0), r(120, 0)], 'x');
61
+ expect(out).not.toBeNull();
62
+ expect(out?.gapPx).toBe(20);
63
+ });
64
+ });
65
+
66
+ describe('detectEqualSpacing / vertical axis', () => {
67
+ test('3 rects stacked, equal gap 30 → detects', () => {
68
+ // y: 0..60, 90..150, 180..240 — gap 30
69
+ const out = detectEqualSpacing([r(0, 0), r(0, 90), r(0, 180)], 'y');
70
+ expect(out).not.toBeNull();
71
+ expect(out?.axis).toBe('y');
72
+ expect(out?.gapPx).toBe(30);
73
+ expect(out?.midpoints).toHaveLength(2);
74
+ expect(out?.midpoints[0]?.y).toBe(75); // 60 + 30/2
75
+ expect(out?.midpoints[1]?.y).toBe(165);
76
+ });
77
+ });
78
+
79
+ describe('detectEqualSpacing / 4+ rects', () => {
80
+ test('4 rects equally distributed → 3 midpoints', () => {
81
+ // gaps 20, 20, 20
82
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(240, 0), r(360, 0)], 'x');
83
+ expect(out).not.toBeNull();
84
+ expect(out?.midpoints).toHaveLength(3);
85
+ expect(out?.gapPx).toBe(20);
86
+ });
87
+
88
+ test('one bad gap in a 4-rect set → null', () => {
89
+ // gaps 20, 50, 20
90
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(270, 0), r(390, 0)], 'x');
91
+ expect(out).toBeNull();
92
+ });
93
+ });