@1agh/maude 0.23.0 → 0.24.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 (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,238 @@
1
+ // Phase 9.2 (DDR-064) Phase C — loop-free disk projection (Tasks 6 + 7).
2
+ //
3
+ // Unit tests for sync/projection.ts in isolation (no runtime / no hub). They
4
+ // pin the contract the shared-doc cutover relies on:
5
+ // - doc→file projects html/css/meta only (the collab room owns comments +
6
+ // annotations doc→file — no double-write);
7
+ // - file→doc imports all five types as a minimal DIFF (never wholesale),
8
+ // tagged FILE_IMPORT so it doesn't echo back to disk;
9
+ // - our own doc→file writes are dropped on their fs echo (hash guard);
10
+ // - a body file edit does NOT clobber concurrent in-doc comments (independent
11
+ // Y-types — the cross-type no-clobber guarantee);
12
+ // - an unparseable file is quarantined after 3 strikes (circuit breaker).
13
+ //
14
+ // NB: true char-level merge of CONCURRENT edits to the SAME body region is
15
+ // Phase 10 (structured CRDT). Here the body is opaque Y.Text + prefix/suffix
16
+ // diff: it preserves untouched regions, but a whole-file /design:edit that omits
17
+ // a concurrent browser body edit will still supersede it. Browsers edit
18
+ // comments/annotations (separate types), so that residual is out of Phase C
19
+ // scope by design.
20
+
21
+ import { describe, expect, test } from 'bun:test';
22
+ import * as Y from 'yjs';
23
+
24
+ import { Y_TYPES } from '../collab/persistence.ts';
25
+ import { MAX_HTML_BYTES, applyHtmlToDoc } from '../sync/codec.ts';
26
+ import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
27
+ import { ORIGINS } from '../sync/origins.ts';
28
+ import { createDocProjection } from '../sync/projection.ts';
29
+
30
+ const enc = (s: string) => new TextEncoder().encode(s);
31
+
32
+ interface Write {
33
+ path: string;
34
+ bytes: string;
35
+ }
36
+
37
+ function makeProjection(paths: {
38
+ html: string;
39
+ comments: string;
40
+ annotations: string;
41
+ meta?: string;
42
+ css?: string;
43
+ }) {
44
+ const doc = new Y.Doc();
45
+ const writes: Write[] = [];
46
+ const echoGuard = createEchoGuard();
47
+ const projection = createDocProjection({
48
+ slug: 'p',
49
+ doc,
50
+ paths,
51
+ echoGuard,
52
+ flushMs: 0, // synchronous flush on the next microtask
53
+ writer: (path, bytes) => writes.push({ path, bytes: bytes.toString() }),
54
+ });
55
+ projection.start();
56
+ return { doc, writes, echoGuard, projection };
57
+ }
58
+
59
+ const PATHS = {
60
+ html: '/d/ui/screen.html',
61
+ comments: '/d/_comments/ui-screen.json',
62
+ annotations: '/d/ui-screen.annotations.svg',
63
+ meta: '/d/ui/screen.meta.json',
64
+ css: '/d/ui/screen.css',
65
+ };
66
+
67
+ const tick = () => new Promise((r) => setTimeout(r, 0));
68
+
69
+ describe('projection doc→file (html/css/meta only — room owns comments/annotations)', () => {
70
+ test('projects html / css / meta on a doc edit', async () => {
71
+ const { doc, writes, projection } = makeProjection(PATHS);
72
+ doc.getText('html').insert(0, '<button>hi</button>');
73
+ doc.getText('css').insert(0, '.b{color:red}');
74
+ // meta is stored as the canonical SHARED subset (a JSON string in Y.Text).
75
+ doc.getText('meta').insert(0, JSON.stringify({ title: 'Screen' }));
76
+ await projection.flush();
77
+
78
+ const byPath = Object.fromEntries(writes.map((w) => [w.path, w.bytes]));
79
+ expect(byPath[PATHS.html]).toBe('<button>hi</button>');
80
+ expect(byPath[PATHS.css]).toBe('.b{color:red}');
81
+ expect(JSON.parse(byPath[PATHS.meta])).toMatchObject({ title: 'Screen' });
82
+ await tick();
83
+ });
84
+
85
+ test('does NOT write comments or annotations doc→file (the room owns those)', async () => {
86
+ const { doc, writes, projection } = makeProjection(PATHS);
87
+ doc.getArray(Y_TYPES.comments).push([{ id: 'c1', text: 'hi' }]);
88
+ doc.getMap(Y_TYPES.annotations).set('svg', '<svg/>');
89
+ await projection.flush();
90
+ expect(writes.find((w) => w.path === PATHS.comments)).toBeUndefined();
91
+ expect(writes.find((w) => w.path === PATHS.annotations)).toBeUndefined();
92
+ });
93
+
94
+ test('never clobbers a non-empty body with an empty doc value (safe reconcile)', async () => {
95
+ const { writes, projection } = makeProjection(PATHS);
96
+ // doc html is empty → reconcile must not write an empty file over local.
97
+ projection.reconcile();
98
+ expect(writes.find((w) => w.path === PATHS.html)).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ describe('projection file→doc (diff-import, all five types, FILE_IMPORT origin)', () => {
103
+ test('imports an external body edit into the doc', () => {
104
+ const { doc, projection } = makeProjection(PATHS);
105
+ const body = '<main>edited</main>';
106
+ const changed = projection.applyFromFs({
107
+ path: PATHS.html,
108
+ bytes: enc(body),
109
+ hash: hashBytes(body),
110
+ });
111
+ expect(changed).toBe(true);
112
+ expect(doc.getText('html').toString()).toBe(body);
113
+ });
114
+
115
+ test('import is tagged FILE_IMPORT (so the projector does not re-project it)', () => {
116
+ const { doc, projection } = makeProjection(PATHS);
117
+ let lastOrigin: unknown;
118
+ doc.on('update', (_u: Uint8Array, origin: unknown) => {
119
+ lastOrigin = origin;
120
+ });
121
+ const body = '<main>x</main>';
122
+ projection.applyFromFs({ path: PATHS.html, bytes: enc(body), hash: hashBytes(body) });
123
+ expect(lastOrigin).toBe(ORIGINS.FILE_IMPORT);
124
+ });
125
+
126
+ test('imports comments + annotations too', () => {
127
+ const { doc, projection } = makeProjection(PATHS);
128
+ const comments = JSON.stringify([{ id: 'x', text: 'yo' }]);
129
+ expect(
130
+ projection.applyFromFs({
131
+ path: PATHS.comments,
132
+ bytes: enc(comments),
133
+ hash: hashBytes(comments),
134
+ })
135
+ ).toBe(true);
136
+ expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'x', text: 'yo' }]);
137
+
138
+ const svg = '<svg><rect/></svg>';
139
+ expect(
140
+ projection.applyFromFs({ path: PATHS.annotations, bytes: enc(svg), hash: hashBytes(svg) })
141
+ ).toBe(true);
142
+ expect(doc.getMap(Y_TYPES.annotations).get('svg')).toBe(svg);
143
+ });
144
+
145
+ test('body import is a MINIMAL diff — preserves an untouched suffix region', () => {
146
+ const { doc, projection } = makeProjection(PATHS);
147
+ doc.getText('html').insert(0, 'HELLO WORLD');
148
+ // Change only the prefix; the diff must keep " WORLD" in place (prefix/suffix
149
+ // elimination), not delete-all + insert-all.
150
+ const next = 'HOWDY WORLD';
151
+ projection.applyFromFs({ path: PATHS.html, bytes: enc(next), hash: hashBytes(next) });
152
+ expect(doc.getText('html').toString()).toBe(next);
153
+ // Cross-check the codec produced a minimal op (the same primitive the
154
+ // projector uses) — a wholesale replace would not preserve the suffix.
155
+ const probe = new Y.Doc();
156
+ probe.getText('html').insert(0, 'HELLO WORLD');
157
+ expect(applyHtmlToDoc(probe, next, 'x')).toBe(true);
158
+ });
159
+ });
160
+
161
+ describe('projection loop-freedom', () => {
162
+ test('drops the fs echo of our own doc→file write (hash guard)', async () => {
163
+ const { doc, projection } = makeProjection(PATHS);
164
+ doc.getText('html').insert(0, '<x/>');
165
+ await projection.flush();
166
+ // The fs.watch fires for our own write — same bytes → same hash → dropped,
167
+ // doc unchanged, no re-import.
168
+ let updates = 0;
169
+ doc.on('update', () => {
170
+ updates++;
171
+ });
172
+ const changed = projection.applyFromFs({
173
+ path: PATHS.html,
174
+ bytes: enc('<x/>'),
175
+ hash: hashBytes('<x/>'),
176
+ });
177
+ expect(changed).toBe(false);
178
+ expect(updates).toBe(0);
179
+ });
180
+
181
+ test('a body file edit does NOT clobber concurrent in-doc comments (cross-type no-clobber)', () => {
182
+ const { doc, projection } = makeProjection(PATHS);
183
+ // Browser added a comment (lives in the comments Y.Array).
184
+ doc.getArray(Y_TYPES.comments).push([{ id: 'live', text: 'mine' }]);
185
+ // Concurrently /design:edit rewrites the whole body file.
186
+ const body = '<main>rewritten by design:edit</main>';
187
+ projection.applyFromFs({ path: PATHS.html, bytes: enc(body), hash: hashBytes(body) });
188
+ // Body updated; the concurrent comment SURVIVED (independent Y-type).
189
+ expect(doc.getText('html').toString()).toBe(body);
190
+ expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'live', text: 'mine' }]);
191
+ });
192
+ });
193
+
194
+ describe('projection hub→disk hardening (security re-audit A2 + A3)', () => {
195
+ test('A2 — refuses to materialize an oversized (hub-pushed) body to disk', async () => {
196
+ const { doc, writes, projection } = makeProjection(PATHS);
197
+ // Simulate a hub raw-update that bypassed the codec import cap: an oversized
198
+ // body lands directly in the shared doc.
199
+ doc.getText('html').insert(0, 'x'.repeat(MAX_HTML_BYTES + 1));
200
+ await projection.flush();
201
+ // The doc→file lane must refuse it — no disk-fill DoS.
202
+ expect(writes.find((w) => w.path === PATHS.html)).toBeUndefined();
203
+ });
204
+
205
+ test('A3 — a meta file with __proto__ is imported without polluting Object.prototype', () => {
206
+ const { doc, projection } = makeProjection(PATHS);
207
+ const malicious = JSON.stringify({ __proto__: { polluted: 'yes' }, title: 'ok' });
208
+ projection.applyFromFs({ path: PATHS.meta, bytes: enc(malicious), hash: hashBytes(malicious) });
209
+ // The dangerous key was stripped at parse time — no prototype pollution and
210
+ // the stored shared meta carries only the safe key.
211
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
212
+ const stored = doc.getText('meta').toString();
213
+ expect(stored).not.toContain('__proto__');
214
+ expect(stored).not.toContain('polluted');
215
+ expect(stored).toContain('ok');
216
+ });
217
+ });
218
+
219
+ describe('projection circuit breaker', () => {
220
+ test('quarantines an unparseable meta file after 3 strikes (until checksum changes)', () => {
221
+ const { doc, projection } = makeProjection(PATHS);
222
+ const broken = '{ not json';
223
+ const ev = { path: PATHS.meta, bytes: enc(broken), hash: hashBytes(broken) };
224
+ // First three attempts: parse fails → false, strikes accumulate.
225
+ expect(projection.applyFromFs(ev)).toBe(false);
226
+ expect(projection.applyFromFs(ev)).toBe(false);
227
+ expect(projection.applyFromFs(ev)).toBe(false);
228
+ // Now quarantined for that hash — still false, but short-circuited.
229
+ expect(projection.applyFromFs(ev)).toBe(false);
230
+
231
+ // A DIFFERENT (valid) payload clears the quarantine and applies.
232
+ const ok = JSON.stringify({ title: 'Fixed' });
233
+ expect(projection.applyFromFs({ path: PATHS.meta, bytes: enc(ok), hash: hashBytes(ok) })).toBe(
234
+ true
235
+ );
236
+ expect(doc.getText('meta').toString()).toContain('Fixed');
237
+ });
238
+ });
@@ -0,0 +1,146 @@
1
+ // Connection-state machine tests — Phase 9 Task 8 (hub-down offline mode).
2
+ //
3
+ // Drives the monitor with injected timers + clock so transitions are
4
+ // deterministic without real wall-clock waits.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { type SyncStatusSnapshot, createConnectionMonitor } from '../sync/connection-state.ts';
9
+
10
+ /** A controllable timer queue: setTimer enqueues, advance() fires due timers. */
11
+ function fakeClock() {
12
+ let nowMs = 1_000_000;
13
+ let nextId = 1;
14
+ const timers = new Map<number, { fireAt: number; cb: () => void }>();
15
+ return {
16
+ now: () => nowMs,
17
+ setTimer: (cb: () => void, ms: number) => {
18
+ const id = nextId++;
19
+ timers.set(id, { fireAt: nowMs + ms, cb });
20
+ return id as unknown as ReturnType<typeof setTimeout>;
21
+ },
22
+ clearTimer: (h: ReturnType<typeof setTimeout>) => {
23
+ timers.delete(h as unknown as number);
24
+ },
25
+ /** Advance virtual time by `ms`, firing every timer whose deadline passed. */
26
+ advance(ms: number) {
27
+ nowMs += ms;
28
+ for (const [id, t] of [...timers.entries()].sort((a, b) => a[1].fireAt - b[1].fireAt)) {
29
+ if (t.fireAt <= nowMs) {
30
+ timers.delete(id);
31
+ t.cb();
32
+ }
33
+ }
34
+ },
35
+ };
36
+ }
37
+
38
+ function makeMonitor(overrides = {}) {
39
+ const clock = fakeClock();
40
+ const changes: SyncStatusSnapshot[] = [];
41
+ const monitor = createConnectionMonitor({
42
+ graceMs: 30_000,
43
+ escalateMs: 24 * 60 * 60 * 1000,
44
+ flashMs: 3_000,
45
+ now: clock.now,
46
+ setTimer: clock.setTimer,
47
+ clearTimer: clock.clearTimer,
48
+ onChange: (s) => changes.push(s),
49
+ ...overrides,
50
+ });
51
+ return { clock, changes, monitor };
52
+ }
53
+
54
+ describe('connection monitor', () => {
55
+ test('starts online and stays online when the provider connects', () => {
56
+ const { monitor } = makeMonitor();
57
+ monitor.noteProviderStatus('p1', 'connected');
58
+ expect(monitor.snapshot().state).toBe('online');
59
+ });
60
+
61
+ test('disconnect → stays connecting during grace, goes offline after graceMs', () => {
62
+ const { clock, monitor } = makeMonitor();
63
+ monitor.noteProviderStatus('p1', 'connected');
64
+ monitor.noteProviderStatus('p1', 'disconnected');
65
+ // Inside the grace window — no offline yet (transient blips don't flash UI).
66
+ expect(monitor.snapshot().state).toBe('connecting');
67
+ clock.advance(29_000);
68
+ expect(monitor.snapshot().state).toBe('connecting');
69
+ clock.advance(2_000); // total 31s > 30s grace
70
+ expect(monitor.snapshot().state).toBe('offline');
71
+ expect(monitor.snapshot().offlineSince).not.toBeNull();
72
+ });
73
+
74
+ test('reconnect inside grace cancels the offline transition', () => {
75
+ const { clock, monitor } = makeMonitor();
76
+ monitor.noteProviderStatus('p1', 'connected');
77
+ monitor.noteProviderStatus('p1', 'disconnected');
78
+ clock.advance(10_000);
79
+ monitor.noteProviderStatus('p1', 'connected');
80
+ expect(monitor.snapshot().state).toBe('online');
81
+ clock.advance(60_000); // grace timer must have been cancelled
82
+ expect(monitor.snapshot().state).toBe('online');
83
+ });
84
+
85
+ test('queues local edits while offline, resets on reconnect with a green flash', () => {
86
+ const { clock, monitor } = makeMonitor();
87
+ monitor.noteProviderStatus('p1', 'connected');
88
+ monitor.noteProviderStatus('p1', 'disconnected');
89
+ clock.advance(31_000);
90
+ expect(monitor.snapshot().state).toBe('offline');
91
+
92
+ monitor.noteLocalEdit();
93
+ monitor.noteLocalEdit();
94
+ monitor.noteLocalEdit();
95
+ expect(monitor.snapshot().queuedOps).toBe(3);
96
+
97
+ monitor.noteProviderStatus('p1', 'connected');
98
+ const snap = monitor.snapshot();
99
+ expect(snap.state).toBe('online');
100
+ expect(snap.queuedOps).toBe(0);
101
+ expect(snap.flash).toBe('synced');
102
+
103
+ clock.advance(3_500); // flashMs elapses
104
+ expect(monitor.snapshot().flash).toBeNull();
105
+ });
106
+
107
+ test('does not count edits made while online toward the queue', () => {
108
+ const { monitor } = makeMonitor();
109
+ monitor.noteProviderStatus('p1', 'connected');
110
+ monitor.noteLocalEdit();
111
+ expect(monitor.snapshot().queuedOps).toBe(0);
112
+ });
113
+
114
+ test('escalates to offline-long after escalateMs offline', () => {
115
+ const { clock, monitor } = makeMonitor();
116
+ monitor.noteProviderStatus('p1', 'connected');
117
+ monitor.noteProviderStatus('p1', 'disconnected');
118
+ clock.advance(31_000);
119
+ expect(monitor.snapshot().state).toBe('offline');
120
+ clock.advance(24 * 60 * 60 * 1000 + 1_000);
121
+ expect(monitor.snapshot().state).toBe('offline-long');
122
+ });
123
+
124
+ test('aggregates multiple providers: any connected ⇒ online', () => {
125
+ const { clock, monitor } = makeMonitor();
126
+ monitor.noteProviderStatus('a', 'connected');
127
+ monitor.noteProviderStatus('b', 'connected');
128
+ // b drops, a still up → stays online (aggregate sees a connected).
129
+ monitor.noteProviderStatus('b', 'disconnected');
130
+ expect(monitor.snapshot().state).toBe('online');
131
+ // a drops too → grace, then offline.
132
+ monitor.noteProviderStatus('a', 'disconnected');
133
+ clock.advance(31_000);
134
+ expect(monitor.snapshot().state).toBe('offline');
135
+ });
136
+
137
+ test('stop() cancels pending timers (no late transitions)', () => {
138
+ const { clock, changes, monitor } = makeMonitor();
139
+ monitor.noteProviderStatus('p1', 'connected');
140
+ monitor.noteProviderStatus('p1', 'disconnected');
141
+ monitor.stop();
142
+ const before = changes.length;
143
+ clock.advance(60_000);
144
+ expect(changes.length).toBe(before); // no offline transition fired
145
+ });
146
+ });
@@ -0,0 +1,123 @@
1
+ // Gap 2 — .meta.json shared-layout sync codec.
2
+ //
3
+ // Artboard layout must propagate as a file diff (one machine moves an artboard →
4
+ // the other's .meta.json layout converges), while each user's pan/zoom
5
+ // (`viewport`) and the security opt-in (`syncable`) stay strictly local. These
6
+ // tests pin that split: applyMetaToDoc stores only the shared subset, and
7
+ // mergeSharedMetaIntoLocal layers it back without trampling local-only keys.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import * as Y from 'yjs';
11
+
12
+ import {
13
+ META_LOCAL_KEYS,
14
+ applyCssToDoc,
15
+ applyMetaToDoc,
16
+ cssFromDoc,
17
+ mergeSharedMetaIntoLocal,
18
+ metaFromDoc,
19
+ } from '../sync/codec.ts';
20
+
21
+ describe('Gap 2 — meta codec: shared layout syncs, per-user keys stay local', () => {
22
+ test('applyMetaToDoc stores ONLY the shared subset (viewport/last_modified/syncable stripped)', () => {
23
+ const doc = new Y.Doc();
24
+ const full = JSON.stringify({
25
+ title: 'Kanban',
26
+ layout: { 'ab-1': { x: 10, y: 20 } },
27
+ viewport: { x: 999, y: 888, zoom: 0.5 },
28
+ last_modified: '2026-01-01T00:00:00Z',
29
+ syncable: true,
30
+ });
31
+ expect(applyMetaToDoc(doc, full)).toBe(true);
32
+
33
+ const shared = JSON.parse(metaFromDoc(doc) as string);
34
+ expect(shared.title).toBe('Kanban');
35
+ expect(shared.layout).toEqual({ 'ab-1': { x: 10, y: 20 } });
36
+ // The three per-machine keys never enter the synced doc.
37
+ expect(shared.viewport).toBeUndefined();
38
+ expect(shared.last_modified).toBeUndefined();
39
+ expect(shared.syncable).toBeUndefined();
40
+ });
41
+
42
+ test('applyMetaToDoc is a NO-OP when only per-user keys differ (no churn from panning)', () => {
43
+ const doc = new Y.Doc();
44
+ applyMetaToDoc(doc, JSON.stringify({ title: 'X', viewport: { x: 1 } }));
45
+ // Same shared content, different viewport → must not register a change,
46
+ // otherwise every pan would emit a sync op.
47
+ expect(applyMetaToDoc(doc, JSON.stringify({ title: 'X', viewport: { x: 2 } }))).toBe(false);
48
+ });
49
+
50
+ test('mergeSharedMetaIntoLocal takes shared layout, PRESERVES local viewport + syncable', () => {
51
+ const shared = JSON.stringify({ title: 'Kanban', layout: { 'ab-1': { x: 10, y: 20 } } });
52
+ const local = JSON.stringify({
53
+ title: 'Kanban (stale)',
54
+ layout: { 'ab-1': { x: 0, y: 0 } },
55
+ viewport: { x: 42, y: 7, zoom: 1.5 },
56
+ syncable: false,
57
+ last_modified: 'local-ts',
58
+ });
59
+ const merged = JSON.parse(mergeSharedMetaIntoLocal(local, shared) as string);
60
+ expect(merged.layout).toEqual({ 'ab-1': { x: 10, y: 20 } }); // shared layout won
61
+ expect(merged.title).toBe('Kanban'); // shared title won
62
+ expect(merged.viewport).toEqual({ x: 42, y: 7, zoom: 1.5 }); // LOCAL viewport kept
63
+ expect(merged.syncable).toBe(false); // LOCAL opt-in kept (security invariant)
64
+ expect(merged.last_modified).toBe('local-ts'); // local timestamp kept
65
+ });
66
+
67
+ test('round-trip: artboard move on A reaches B without touching B viewport / syncable', () => {
68
+ const aDoc = new Y.Doc();
69
+ applyMetaToDoc(
70
+ aDoc,
71
+ JSON.stringify({
72
+ title: 'K',
73
+ layout: { 'ab-1': { x: 100, y: 200 } },
74
+ viewport: { zoom: 0.5 },
75
+ syncable: true,
76
+ })
77
+ );
78
+ const shared = metaFromDoc(aDoc) as string;
79
+
80
+ const bLocal = JSON.stringify({
81
+ title: 'K',
82
+ layout: { 'ab-1': { x: 0, y: 0 } },
83
+ viewport: { zoom: 2.0 },
84
+ syncable: false,
85
+ });
86
+ const bMerged = JSON.parse(mergeSharedMetaIntoLocal(bLocal, shared) as string);
87
+ expect(bMerged.layout['ab-1']).toEqual({ x: 100, y: 200 }); // A's move landed on B
88
+ expect(bMerged.viewport).toEqual({ zoom: 2.0 }); // B's camera untouched
89
+ expect(bMerged.syncable).toBe(false); // B's opt-in untouched
90
+ });
91
+
92
+ test('a freshly-cloned peer with no local viewport just takes the shared subset', () => {
93
+ const merged = JSON.parse(
94
+ mergeSharedMetaIntoLocal('{}', JSON.stringify({ title: 'K', layout: { a: 1 } })) as string
95
+ );
96
+ expect(merged).toEqual({ title: 'K', layout: { a: 1 } });
97
+ });
98
+
99
+ test('META_LOCAL_KEYS pins the never-synced set', () => {
100
+ expect([...META_LOCAL_KEYS].sort()).toEqual(['last_modified', 'syncable', 'viewport']);
101
+ });
102
+
103
+ test('mergeSharedMetaIntoLocal returns null on an unparseable shared payload', () => {
104
+ expect(mergeSharedMetaIntoLocal('{}', 'not json')).toBeNull();
105
+ });
106
+ });
107
+
108
+ describe('Gap 3 — css codec (opaque wholesale text)', () => {
109
+ test('applyCssToDoc round-trips the css string', () => {
110
+ const doc = new Y.Doc();
111
+ expect(cssFromDoc(doc)).toBeNull();
112
+ const css = '.ka-card { color: red; }\n';
113
+ expect(applyCssToDoc(doc, css)).toBe(true);
114
+ expect(cssFromDoc(doc)).toBe(css);
115
+ });
116
+
117
+ test('applyCssToDoc is a no-op when unchanged (loop-safe)', () => {
118
+ const doc = new Y.Doc();
119
+ applyCssToDoc(doc, '.x{}');
120
+ expect(applyCssToDoc(doc, '.x{}')).toBe(false);
121
+ expect(applyCssToDoc(doc, '.x{ color: blue }')).toBe(true);
122
+ });
123
+ });