@1agh/maude 0.22.2 → 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 (125) 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 +84 -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 +135 -10
  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 +222 -11
  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/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,812 @@
1
+ // Sync runtime wiring tests — Phase 9 Task 4.
2
+ //
3
+ // Uses an in-memory ProviderFactory so we don't need to start a Hocuspocus
4
+ // server. The agents do real Y.Doc work; the test verifies the runtime
5
+ // honors linkedHub config, discovers canvases, wires up agents, and
6
+ // dispatches fs events through the bus correctly.
7
+
8
+ import {
9
+ chmodSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { Awareness } from 'y-protocols/awareness';
22
+ import * as Y from 'yjs';
23
+
24
+ import { Y_TYPES } from '../collab/persistence.ts';
25
+ import { createRegistry } from '../collab/registry.ts';
26
+ import type { RoomCallbacks } from '../collab/room.ts';
27
+ import type { Context, DevServerConfig } from '../context.ts';
28
+ import { createBus } from '../context.ts';
29
+ import { createConnectionMonitor } from '../sync/connection-state.ts';
30
+ import {
31
+ type AwarenessRegistry,
32
+ type SyncProvider,
33
+ buildNoSyncablePayload,
34
+ createSyncRuntime,
35
+ discoverCanvases,
36
+ scanCanvases,
37
+ toWsUrl,
38
+ } from '../sync/index.ts';
39
+ import { createSyncStatusStore } from '../sync/status.ts';
40
+
41
+ let dir: string;
42
+ let cfgPathEnv: string | undefined;
43
+
44
+ beforeEach(() => {
45
+ dir = mkdtempSync(join(tmpdir(), 'sync-runtime-'));
46
+ cfgPathEnv = process.env.HUBS_CONFIG_PATH;
47
+ // Point hubs.json to a temp file we control.
48
+ process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
49
+ });
50
+
51
+ afterEach(() => {
52
+ // Node's process.env stringifies on assignment; assigning undefined yields
53
+ // the literal string "undefined". delete is the correct restoration.
54
+ // biome-ignore lint/performance/noDelete: process.env semantics.
55
+ if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
56
+ else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
57
+ rmSync(dir, { recursive: true, force: true });
58
+ });
59
+
60
+ function writeHubsConfig(url: string, token: string): void {
61
+ const cfgPath = process.env.HUBS_CONFIG_PATH;
62
+ if (!cfgPath) throw new Error('HUBS_CONFIG_PATH not set by beforeEach');
63
+ writeFileSync(cfgPath, JSON.stringify({ hubs: { [url]: { token, linkedAt: 1 } } }));
64
+ // Match the CLI's saveHubsConfig mode so the DDR-054 §2h mode-warning
65
+ // doesn't fire on every test.
66
+ chmodSync(cfgPath, 0o600);
67
+ }
68
+
69
+ function makeCtx(linkedHub?: DevServerConfig['linkedHub'], canvasOrigin?: string): Context {
70
+ // Minimal Context — only the fields the sync runtime touches.
71
+ const designRoot = join(dir, 'design');
72
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
73
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
74
+ return {
75
+ // T3 (9.1-B) — `canvasOrigin` set === the CSP/sandbox split is active
76
+ // (MAUDE_CANVAS_ORIGIN_SPLIT=1). The `.tsx` sync opt-in is gated on it.
77
+ canvasOrigin,
78
+ cfg: {
79
+ name: 'test',
80
+ projectLabel: null,
81
+ designRoot: 'design',
82
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
83
+ rootClass: 'app',
84
+ themeDefault: 'dark',
85
+ tokensCssRel: 'system/colors.css',
86
+ teamAccentDefault: null,
87
+ handoffTargets: [],
88
+ newCanvasDir: 'ui',
89
+ newComponentDir: 'ui/components',
90
+ linkedHub,
91
+ _source: 'defaults',
92
+ },
93
+ projectLabel: 'test',
94
+ paths: {
95
+ repoRoot: dir,
96
+ designRel: 'design',
97
+ designRoot,
98
+ serverInfoFile: join(designRoot, '_server.json'),
99
+ activeFile: join(designRoot, '_active.json'),
100
+ commentsDir: join(designRoot, '_comments'),
101
+ canvasStateDir: join(designRoot, '_canvas-state'),
102
+ historyDir: join(designRoot, '_history'),
103
+ tokensUrlRel: 'design/system/colors.css',
104
+ systemDirRel: 'system',
105
+ },
106
+ bus: createBus(),
107
+ };
108
+ }
109
+
110
+ function inMemoryProviderFactory(): {
111
+ factory: (args: {
112
+ url: string;
113
+ token: string;
114
+ documentName: string;
115
+ }) => SyncProvider;
116
+ peerOf: (slug: string) => Y.Doc;
117
+ } {
118
+ // Map of slug -> { local, peer } Y.Docs cross-linked via applyUpdate.
119
+ const peers = new Map<string, { local: Y.Doc; peer: Y.Doc }>();
120
+ const TRANSPORT = Symbol('test-transport');
121
+
122
+ function factory(args: {
123
+ url: string;
124
+ token: string;
125
+ documentName: string;
126
+ document?: Y.Doc;
127
+ }): SyncProvider {
128
+ // Phase 9.2 (DDR-064): when the runtime injects a doc (sharedDoc ON), the
129
+ // provider MUST attach to it — that's the whole point of convergence. The
130
+ // peer is a second doc cross-linked through a mock transport, modelling
131
+ // "another machine on the hub". We own `local` only when we created it, so
132
+ // we don't destroy the registry-owned shared doc on teardown.
133
+ const ownsLocal = !args.document;
134
+ const local = args.document ?? new Y.Doc();
135
+ const peer = new Y.Doc();
136
+ local.on('update', (update: Uint8Array, origin: unknown) => {
137
+ if (origin === TRANSPORT) return;
138
+ Y.applyUpdate(peer, update, TRANSPORT);
139
+ });
140
+ peer.on('update', (update: Uint8Array, origin: unknown) => {
141
+ if (origin === TRANSPORT) return;
142
+ Y.applyUpdate(local, update, TRANSPORT);
143
+ });
144
+ peers.set(args.documentName, { local, peer });
145
+ return {
146
+ document: local,
147
+ awareness: new Awareness(local),
148
+ async onceSynced() {
149
+ // Synced immediately for the in-memory pair.
150
+ },
151
+ destroy() {
152
+ if (ownsLocal) local.destroy();
153
+ peer.destroy();
154
+ },
155
+ };
156
+ }
157
+
158
+ return {
159
+ factory,
160
+ peerOf(slug: string): Y.Doc {
161
+ const entry = peers.get(slug);
162
+ if (!entry) throw new Error(`no provider for slug ${slug}`);
163
+ return entry.peer;
164
+ },
165
+ };
166
+ }
167
+
168
+ describe('createSyncRuntime', () => {
169
+ test('returns null when linkedHub is absent (solo mode)', () => {
170
+ const ctx = makeCtx(undefined);
171
+ expect(createSyncRuntime(ctx)).toBeNull();
172
+ });
173
+
174
+ test('returns null when token is missing from hubs.json', () => {
175
+ const ctx = makeCtx({ url: 'https://hub.example.com', linkedAt: 1 });
176
+ // No hubs.json written — token lookup returns null.
177
+ expect(createSyncRuntime(ctx)).toBeNull();
178
+ });
179
+
180
+ test('starts agents for each discovered canvas', async () => {
181
+ const url = 'https://hub.example.com';
182
+ writeHubsConfig(url, 'mau_test');
183
+ const ctx = makeCtx({ url, linkedAt: 1 });
184
+
185
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
186
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'modal.html'), '<dialog>x</dialog>');
187
+
188
+ const { factory } = inMemoryProviderFactory();
189
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
190
+ expect(runtime).not.toBeNull();
191
+
192
+ await runtime?.start();
193
+ expect(runtime?.size()).toBe(2);
194
+ expect(runtime?.agentFor('ui-screen')).toBeDefined();
195
+ expect(runtime?.agentFor('ui-modal')).toBeDefined();
196
+
197
+ await runtime?.stop();
198
+ });
199
+
200
+ test('9.1-D: TSX-only project surfaces zero-syncable loudly (no silent no-op)', async () => {
201
+ const url = 'https://hub.example.com';
202
+ writeHubsConfig(url, 'mau_test');
203
+ const ctx = makeCtx({ url, linkedAt: 1 });
204
+
205
+ // Real (TSX-only) project: discovery admits .html only, so these don't sync.
206
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.tsx'), 'export default () => null;');
207
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'modal.tsx'), 'export default () => null;');
208
+
209
+ const events: unknown[] = [];
210
+ ctx.bus.on('sync:status', (p) => events.push(p));
211
+
212
+ const { factory } = inMemoryProviderFactory();
213
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
214
+ await runtime?.start();
215
+
216
+ // No agents — but the state is now LOUD, not silent.
217
+ expect(runtime?.size()).toBe(0);
218
+
219
+ const syncFile = join(ctx.paths.designRoot, '_sync.json');
220
+ expect(existsSync(syncFile)).toBe(true);
221
+ const payload = JSON.parse(readFileSync(syncFile, 'utf8'));
222
+ expect(payload.notSyncable).toBe(true);
223
+ expect(payload.tsxCount).toBe(2);
224
+ expect(payload.canvases).toBe(0);
225
+ expect(payload.url).toBe(url);
226
+ expect(payload.reason).toContain('DDR-060');
227
+
228
+ // The browser banner gets the same payload over the bus.
229
+ expect(events).toHaveLength(1);
230
+ expect((events[0] as { notSyncable?: boolean }).notSyncable).toBe(true);
231
+
232
+ await runtime?.stop();
233
+ });
234
+
235
+ test('adopt mode: pushes local disk state up to the hub on first sync', async () => {
236
+ const url = 'https://hub.example.com';
237
+ writeHubsConfig(url, 'mau_test');
238
+ const ctx = makeCtx({ url, linkedAt: 1, adopt: true });
239
+
240
+ writeFileSync(
241
+ join(ctx.paths.designRoot, 'ui', 'screen.html'),
242
+ '<button>local-bootstrap</button>'
243
+ );
244
+
245
+ const { factory, peerOf } = inMemoryProviderFactory();
246
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
247
+ await runtime?.start();
248
+
249
+ // Reconcile fires after onceSynced() resolves — give one tick.
250
+ await new Promise((res) => setTimeout(res, 10));
251
+
252
+ expect(peerOf('ui-screen').getText('html').toString()).toBe('<button>local-bootstrap</button>');
253
+ await runtime?.stop();
254
+ });
255
+
256
+ test('bus fs:any event dispatches through the agent', async () => {
257
+ const url = 'https://hub.example.com';
258
+ writeHubsConfig(url, 'mau_test');
259
+ const ctx = makeCtx({ url, linkedAt: 1 });
260
+
261
+ const htmlPath = join(ctx.paths.designRoot, 'ui', 'screen.html');
262
+ writeFileSync(htmlPath, '');
263
+
264
+ const { factory, peerOf } = inMemoryProviderFactory();
265
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
266
+ await runtime?.start();
267
+
268
+ // Simulate a local edit: write to disk, then fire the bus event the
269
+ // existing fs-watch.ts would emit.
270
+ writeFileSync(htmlPath, '<button>local</button>');
271
+ ctx.bus.emit('fs:any', 'ui/screen.html');
272
+
273
+ // Wait for fs-mirror's 250ms quiet window + agent flush slack.
274
+ await new Promise((res) => setTimeout(res, 400));
275
+
276
+ expect(peerOf('ui-screen').getText('html').toString()).toBe('<button>local</button>');
277
+ await runtime?.stop();
278
+ });
279
+
280
+ test('attaches each provider awareness to the registry and detaches on stop', async () => {
281
+ const url = 'https://hub.example.com';
282
+ writeHubsConfig(url, 'mau_test');
283
+ const ctx = makeCtx({ url, linkedAt: 1 });
284
+
285
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
286
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'modal.html'), '<dialog>x</dialog>');
287
+
288
+ const attached: string[] = [];
289
+ let detachCount = 0;
290
+ const registry: AwarenessRegistry = {
291
+ attachHubAwareness(slug, _awareness) {
292
+ attached.push(slug);
293
+ return () => {
294
+ detachCount++;
295
+ };
296
+ },
297
+ };
298
+
299
+ const { factory } = inMemoryProviderFactory();
300
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
301
+ await runtime?.start();
302
+
303
+ expect(attached.sort()).toEqual(['ui-modal', 'ui-screen']);
304
+
305
+ await runtime?.stop();
306
+ expect(detachCount).toBe(2);
307
+ });
308
+
309
+ test('Task 8: provider going offline drives the status to offline + surfaces queued edits', async () => {
310
+ const url = 'https://hub.example.com';
311
+ writeHubsConfig(url, 'mau_test');
312
+ const ctx = makeCtx({ url, linkedAt: 1 });
313
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
314
+
315
+ // Provider stub that exposes onStatus so the test can drive WS transitions.
316
+ let emitStatus: ((s: 'connected' | 'connecting' | 'disconnected') => void) | null = null;
317
+ const doc = new Y.Doc();
318
+ const factory = () => ({
319
+ document: doc,
320
+ onStatus(cb: (s: 'connected' | 'connecting' | 'disconnected') => void) {
321
+ emitStatus = cb;
322
+ return () => {
323
+ emitStatus = null;
324
+ };
325
+ },
326
+ async onceSynced() {},
327
+ destroy() {
328
+ doc.destroy();
329
+ },
330
+ });
331
+
332
+ // Fake-timer monitor so the 30s grace window fires deterministically.
333
+ let nowMs = 1_000;
334
+ const timers: Array<{ fireAt: number; cb: () => void }> = [];
335
+ const monitor = createConnectionMonitor({
336
+ graceMs: 30_000,
337
+ now: () => nowMs,
338
+ setTimer: (cb, ms) => {
339
+ const t = { fireAt: nowMs + ms, cb };
340
+ timers.push(t);
341
+ return t as unknown as ReturnType<typeof setTimeout>;
342
+ },
343
+ clearTimer: (h) => {
344
+ const i = timers.indexOf(h as unknown as { fireAt: number; cb: () => void });
345
+ if (i >= 0) timers.splice(i, 1);
346
+ },
347
+ onChange: (snap) => store.update(snap),
348
+ });
349
+ const writes: import('../sync/status.ts').SyncStatusPayload[] = [];
350
+ const store = createSyncStatusStore({
351
+ url,
352
+ canvases: 1,
353
+ write: (p) => writes.push(p),
354
+ });
355
+
356
+ const runtime = createSyncRuntime(ctx, {
357
+ providerFactory: factory,
358
+ connectionMonitor: monitor,
359
+ statusStore: store,
360
+ });
361
+ await runtime?.start();
362
+
363
+ // Connected → online.
364
+ emitStatus?.('connected');
365
+ expect(runtime?.status()?.state).toBe('online');
366
+
367
+ // Disconnect, advance past the grace window → offline.
368
+ emitStatus?.('disconnected');
369
+ nowMs += 31_000;
370
+ for (const t of [...timers]) {
371
+ if (t.fireAt <= nowMs) {
372
+ timers.splice(timers.indexOf(t), 1);
373
+ t.cb();
374
+ }
375
+ }
376
+ const offlineStatus = runtime?.status();
377
+ expect(offlineStatus?.state).toBe('offline');
378
+ expect(offlineStatus?.url).toBe(url);
379
+ expect(offlineStatus?.offlineSince).not.toBeNull();
380
+ // _sync.json mirror got the offline payload too.
381
+ expect(writes.at(-1)?.state).toBe('offline');
382
+
383
+ // Reconnect → back online with a green flash.
384
+ emitStatus?.('connected');
385
+ expect(runtime?.status()?.state).toBe('online');
386
+ expect(runtime?.status()?.flash).toBe('synced');
387
+
388
+ await runtime?.stop();
389
+ });
390
+
391
+ test('writes _sync.json on a clean fast connect (regression: status must not read "idle" while sync is healthy)', async () => {
392
+ const url = 'https://hub.example.com';
393
+ writeHubsConfig(url, 'mau_test');
394
+ const ctx = makeCtx({ url, linkedAt: 1 });
395
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
396
+
397
+ // Provider that — like the real wrapper after the fix — reports its current
398
+ // status the instant a subscriber attaches, and is already synced. No later
399
+ // WS *transition* fires (the monitor starts 'online', so a 'connected' note
400
+ // is a no-op), so the only thing that writes `_sync.json` is the runtime's
401
+ // initial status persist. Before the fix this left the file absent and
402
+ // `maude design status` reported "idle / sync agent not running".
403
+ const doc = new Y.Doc();
404
+ const factory = () => ({
405
+ document: doc,
406
+ onStatus(cb: (s: 'connected' | 'connecting' | 'disconnected') => void) {
407
+ cb('connected');
408
+ return () => {};
409
+ },
410
+ async onceSynced() {},
411
+ destroy() {
412
+ doc.destroy();
413
+ },
414
+ });
415
+
416
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
417
+ await runtime?.start();
418
+
419
+ // Live status reflects an online agent...
420
+ expect(runtime?.status()?.state).toBe('online');
421
+ // ...and it has been persisted to _sync.json (what `maude design status` reads).
422
+ const syncFile = join(ctx.paths.designRoot, '_sync.json');
423
+ expect(existsSync(syncFile)).toBe(true);
424
+ const payload = JSON.parse(readFileSync(syncFile, 'utf8'));
425
+ expect(payload.state).toBe('online');
426
+ expect(payload.canvases).toBe(1);
427
+ expect(payload.notSyncable).toBeUndefined();
428
+
429
+ await runtime?.stop();
430
+ });
431
+
432
+ test('Task 8: hub-wins reconcile over divergent local content records a conflict', async () => {
433
+ const url = 'https://hub.example.com';
434
+ writeHubsConfig(url, 'mau_test');
435
+ const ctx = makeCtx({ url, linkedAt: 1 });
436
+ // Local disk has divergent, non-empty content.
437
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>LOCAL EDIT</button>');
438
+
439
+ // Provider whose doc already carries different hub state.
440
+ const factory = () => {
441
+ const document = new Y.Doc();
442
+ document.getText('html').insert(0, '<button>HUB STATE</button>');
443
+ return {
444
+ document,
445
+ async onceSynced() {},
446
+ destroy() {
447
+ document.destroy();
448
+ },
449
+ };
450
+ };
451
+
452
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
453
+ await runtime?.start();
454
+ // Reconcile fires after onceSynced resolves.
455
+ await new Promise((res) => setTimeout(res, 20));
456
+
457
+ const status = runtime?.status();
458
+ expect(status?.conflicts.length).toBeGreaterThanOrEqual(1);
459
+ expect(status?.conflicts[0].kind).toBe('cold-start-hub-wins');
460
+ await runtime?.stop();
461
+ });
462
+ });
463
+
464
+ // Phase 9.2 (DDR-064) — the convergence core: ONE shared Y.Doc per canvas, the
465
+ // hub provider attached to it instead of a fresh doc. These prove (Task 3) that
466
+ // browser↔hub propagation flows through the single doc with the in-process
467
+ // relay RETIRED, and (Task 3 lifecycle) that a provider-pinned room survives the
468
+ // last-browser-leaves drop. Flag-OFF behavior is covered by every other test in
469
+ // this file running with `ctx.sharedDoc` unset.
470
+ describe('shared-doc convergence (MAUDE_SHARED_DOC ON)', () => {
471
+ function noopCallbacks(): RoomCallbacks {
472
+ return { async seed() {}, async persistJson() {}, async persistBinary() {} };
473
+ }
474
+
475
+ /** The real registry wrapped to COUNT relay calls, so a test can assert the
476
+ * wholesale syncRoomFrom* clobber path was never used under sharedDoc. All
477
+ * other methods delegate to the real registry (closures over its state). */
478
+ function countingRegistry() {
479
+ const real = createRegistry(noopCallbacks());
480
+ let relayCalls = 0;
481
+ let awarenessAttaches = 0;
482
+ const wrapped: AwarenessRegistry & {
483
+ getDoc(slug: string): Y.Doc;
484
+ peek: typeof real.peek;
485
+ get: typeof real.get;
486
+ drop: typeof real.drop;
487
+ destroyAll: typeof real.destroyAll;
488
+ relayCalls(): number;
489
+ awarenessAttaches(): number;
490
+ } = {
491
+ getDoc: (slug) => real.getDoc(slug),
492
+ peek: (slug) => real.peek(slug),
493
+ get: (slug) => real.get(slug),
494
+ drop: (slug) => real.drop(slug),
495
+ destroyAll: () => real.destroyAll(),
496
+ pin: (slug) => real.pin(slug),
497
+ unpin: (slug) => real.unpin(slug),
498
+ attachHubAwareness: (slug, awareness) => {
499
+ awarenessAttaches++;
500
+ return real.attachHubAwareness(slug, awareness);
501
+ },
502
+ syncRoomFromComments: (slug, comments) => {
503
+ relayCalls++;
504
+ real.syncRoomFromComments(slug, comments);
505
+ },
506
+ syncRoomFromAnnotations: (slug, svg) => {
507
+ relayCalls++;
508
+ real.syncRoomFromAnnotations(slug, svg);
509
+ },
510
+ relayCalls: () => relayCalls,
511
+ awarenessAttaches: () => awarenessAttaches,
512
+ };
513
+ return wrapped;
514
+ }
515
+
516
+ test('browser edit on the shared doc reaches the hub peer with NO relay', async () => {
517
+ const url = 'https://hub.example.com';
518
+ writeHubsConfig(url, 'mau_test');
519
+ const ctx = makeCtx({ url, linkedAt: 1 });
520
+ ctx.sharedDoc = true; // flag ON
521
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
522
+
523
+ const registry = countingRegistry();
524
+ const { factory, peerOf } = inMemoryProviderFactory();
525
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
526
+ await runtime?.start();
527
+
528
+ // The provider is attached to the SAME doc the registry hands the browser.
529
+ const shared = registry.getDoc('ui-screen');
530
+ // Browser-style edit — a doc mutation whose origin is NOT the agent origin
531
+ // (exactly what a browser conn or the inspector-write path produces).
532
+ shared.getArray(Y_TYPES.comments).push([{ id: 'c1', text: 'hi' }]);
533
+
534
+ // Converges to the hub peer through the provider transport — one doc.
535
+ expect(peerOf('ui-screen').getArray(Y_TYPES.comments).toArray()).toEqual([
536
+ { id: 'c1', text: 'hi' },
537
+ ]);
538
+ // And the wholesale relay (the Phase 9.1 clobber path) was NEVER invoked.
539
+ expect(registry.relayCalls()).toBe(0);
540
+
541
+ await runtime?.stop();
542
+ });
543
+
544
+ test('hub-pushed edit lands in the shared doc (provider applies to the room doc)', async () => {
545
+ const url = 'https://hub.example.com';
546
+ writeHubsConfig(url, 'mau_test');
547
+ const ctx = makeCtx({ url, linkedAt: 1 });
548
+ ctx.sharedDoc = true;
549
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
550
+
551
+ const registry = countingRegistry();
552
+ const { factory, peerOf } = inMemoryProviderFactory();
553
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
554
+ await runtime?.start();
555
+
556
+ // Another machine on the hub adds a comment → reaches our shared doc.
557
+ peerOf('ui-screen')
558
+ .getArray(Y_TYPES.comments)
559
+ .push([{ id: 'h1', text: 'from peer' }]);
560
+ expect(registry.getDoc('ui-screen').getArray(Y_TYPES.comments).toArray()).toEqual([
561
+ { id: 'h1', text: 'from peer' },
562
+ ]);
563
+ expect(registry.relayCalls()).toBe(0);
564
+
565
+ await runtime?.stop();
566
+ });
567
+
568
+ test('two-way merge: concurrent comment + annotation on both sides converge (no clobber)', async () => {
569
+ const url = 'https://hub.example.com';
570
+ writeHubsConfig(url, 'mau_test');
571
+ const ctx = makeCtx({ url, linkedAt: 1 });
572
+ ctx.sharedDoc = true;
573
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
574
+
575
+ const registry = countingRegistry();
576
+ const { factory, peerOf } = inMemoryProviderFactory();
577
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
578
+ await runtime?.start();
579
+
580
+ const shared = registry.getDoc('ui-screen');
581
+ const peer = peerOf('ui-screen');
582
+ // Local adds a comment; the hub peer concurrently sets an annotation.
583
+ shared.getArray(Y_TYPES.comments).push([{ id: 'local-c', text: 'mine' }]);
584
+ peer.getMap(Y_TYPES.annotations).set('svg', '<svg><rect/></svg>');
585
+
586
+ // Both survive on both replicas — the CRDT merged, nothing clobbered.
587
+ expect(shared.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'local-c', text: 'mine' }]);
588
+ expect(shared.getMap(Y_TYPES.annotations).get('svg')).toBe('<svg><rect/></svg>');
589
+ expect(peer.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'local-c', text: 'mine' }]);
590
+ expect(peer.getMap(Y_TYPES.annotations).get('svg')).toBe('<svg><rect/></svg>');
591
+ expect(registry.relayCalls()).toBe(0);
592
+
593
+ await runtime?.stop();
594
+ });
595
+
596
+ test('a provider-pinned shared room survives the last-browser-leaves drop, released on stop', async () => {
597
+ const url = 'https://hub.example.com';
598
+ writeHubsConfig(url, 'mau_test');
599
+ const ctx = makeCtx({ url, linkedAt: 1 });
600
+ ctx.sharedDoc = true;
601
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
602
+
603
+ const registry = countingRegistry();
604
+ const { factory } = inMemoryProviderFactory();
605
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
606
+ await runtime?.start();
607
+
608
+ // The room exists (created by getDoc at attach) with zero browser conns.
609
+ expect(registry.peek('ui-screen')).not.toBeNull();
610
+ // The last-browser-leaves close handler calls drop; pinned → NOT destroyed,
611
+ // or the doc would be yanked out from under the live provider.
612
+ await registry.drop('ui-screen');
613
+ expect(registry.peek('ui-screen')).not.toBeNull();
614
+
615
+ // Runtime stop releases the pin → drop now destroys normally.
616
+ await runtime?.stop();
617
+ await registry.drop('ui-screen');
618
+ expect(registry.peek('ui-screen')).toBeNull();
619
+ });
620
+
621
+ test('Task 4 — the provider awareness is still bridged on the shared-doc path', async () => {
622
+ const url = 'https://hub.example.com';
623
+ writeHubsConfig(url, 'mau_test');
624
+ const ctx = makeCtx({ url, linkedAt: 1 });
625
+ ctx.sharedDoc = true;
626
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
627
+
628
+ const registry = countingRegistry();
629
+ const { factory } = inMemoryProviderFactory();
630
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
631
+ await runtime?.start();
632
+
633
+ // Presence bridging is mode-independent (the attachHubAwareness call sits
634
+ // outside the sharedDoc branch). Confirm it still fires here so cursors
635
+ // relay cross-machine under the new path too.
636
+ expect(registry.awarenessAttaches()).toBe(1);
637
+
638
+ await runtime?.stop();
639
+ });
640
+
641
+ test('Task 11 — status surfaces the shared-doc model in _sync.json', async () => {
642
+ const url = 'https://hub.example.com';
643
+ writeHubsConfig(url, 'mau_test');
644
+ const ctx = makeCtx({ url, linkedAt: 1 });
645
+ ctx.sharedDoc = true;
646
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
647
+
648
+ const registry = countingRegistry();
649
+ const { factory } = inMemoryProviderFactory();
650
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
651
+ await runtime?.start();
652
+
653
+ expect(runtime?.status()?.sharedDoc).toBe(true);
654
+ const payload = JSON.parse(readFileSync(join(ctx.paths.designRoot, '_sync.json'), 'utf8'));
655
+ expect(payload.sharedDoc).toBe(true);
656
+
657
+ await runtime?.stop();
658
+ });
659
+
660
+ // Phase D (Task 8) — the body-gating security invariant under the shared-doc
661
+ // path: a `.tsx` body crosses the hub ONLY with the syncable opt-in (Lock 1) +
662
+ // the canvasOrigin sandbox (Lock 2). The gate is discovery-exclusion: an
663
+ // opted-out `.tsx` is never in the sync set, so no provider attaches to a doc
664
+ // for it and its body is never exposed (DDR-054 F1 / DDR-060).
665
+ test('Phase D — an opted-OUT .tsx body never gets a shared doc/provider (gate holds)', async () => {
666
+ const url = 'https://hub.example.com';
667
+ writeHubsConfig(url, 'mau_test');
668
+ // canvasOrigin set === Lock 2 (sandbox) active.
669
+ const ctx = makeCtx({ url, linkedAt: 1 }, 'http://localhost:9');
670
+ ctx.sharedDoc = true;
671
+
672
+ // Opted-IN .tsx (Lock 1 + Lock 2 both set) → body may sync.
673
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'opted.tsx'), 'export default () => null;');
674
+ writeFileSync(
675
+ join(ctx.paths.designRoot, 'ui', 'opted.meta.json'),
676
+ JSON.stringify({ syncable: true })
677
+ );
678
+ // Opted-OUT .tsx (no Lock 1) → body MUST NOT cross the hub.
679
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'secret.tsx'), 'export default () => SECRET;');
680
+ writeFileSync(
681
+ join(ctx.paths.designRoot, 'ui', 'secret.meta.json'),
682
+ JSON.stringify({ syncable: false })
683
+ );
684
+
685
+ const registry = countingRegistry();
686
+ const { factory } = inMemoryProviderFactory();
687
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
688
+ await runtime?.start();
689
+
690
+ // Only the opted-in canvas is in the sync set.
691
+ expect(runtime?.size()).toBe(1);
692
+ // The opted-in canvas got a shared doc (room created via getDoc)…
693
+ expect(registry.peek('ui-opted')).not.toBeNull();
694
+ // …the opted-out one NEVER did — no provider, no doc, body unexposed.
695
+ expect(registry.peek('ui-secret')).toBeNull();
696
+
697
+ // The untrusted marker lists ONLY the body-exposing (opted-in) canvas.
698
+ const index = JSON.parse(
699
+ readFileSync(join(ctx.paths.designRoot, '_untrusted', 'INDEX.json'), 'utf8')
700
+ );
701
+ const markedSlugs = index.canvases.map((c: { slug: string }) => c.slug);
702
+ expect(markedSlugs).toEqual(['ui-opted']);
703
+
704
+ await runtime?.stop();
705
+ });
706
+ });
707
+
708
+ describe('discoverCanvases', () => {
709
+ test('finds .html files but EXCLUDES .tsx (DDR-054 §2b)', async () => {
710
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 });
711
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '');
712
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.tsx'), '');
713
+
714
+ const list = await discoverCanvases(ctx);
715
+ const slugs = list.map((c) => c.slug).sort();
716
+ // .tsx is deliberately refused — hostile-hub-pushed JSX would be
717
+ // transpiled and executed in iframe same-origin. Solo-mode editing
718
+ // of .tsx is unaffected.
719
+ expect(slugs).toEqual(['ui-a']);
720
+ });
721
+
722
+ test('skips dirs starting with _ (e.g. _history, _comments)', async () => {
723
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 });
724
+ mkdirSync(join(ctx.paths.designRoot, 'ui', '_history'));
725
+ writeFileSync(join(ctx.paths.designRoot, 'ui', '_history', 'snap.html'), '');
726
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'real.html'), '');
727
+
728
+ const list = await discoverCanvases(ctx);
729
+ expect(list.map((c) => c.slug)).toEqual(['ui-real']);
730
+ });
731
+
732
+ // T3 (9.1-B) — opted-in .tsx sync, gated on the sandbox split.
733
+ test('admits an opted-in .tsx ONLY when the split is active (Lock1⊃Lock2)', async () => {
734
+ const HUB = { url: 'https://h.example.com', linkedAt: 1 };
735
+ // split ACTIVE (canvasOrigin set) + sidecar opts in → admitted, body = .tsx.
736
+ const ctx = makeCtx(HUB, 'http://localhost:9');
737
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'opted.tsx'), 'export default () => null;');
738
+ writeFileSync(
739
+ join(ctx.paths.designRoot, 'ui', 'opted.meta.json'),
740
+ JSON.stringify({ syncable: true })
741
+ );
742
+ const list = await discoverCanvases(ctx);
743
+ const opted = list.find((c) => c.slug === 'ui-opted');
744
+ expect(opted).toBeDefined();
745
+ expect(opted?.html).toBe(join(ctx.paths.designRoot, 'ui', 'opted.tsx'));
746
+ });
747
+
748
+ test('does NOT admit an opted-in .tsx when the split is OFF (the coupling)', async () => {
749
+ // Same opt-in, but canvasOrigin undefined → sandbox not in force → refused.
750
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 });
751
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'opted.tsx'), 'export default () => null;');
752
+ writeFileSync(
753
+ join(ctx.paths.designRoot, 'ui', 'opted.meta.json'),
754
+ JSON.stringify({ syncable: true })
755
+ );
756
+ const list = await discoverCanvases(ctx);
757
+ expect(list.map((c) => c.slug)).not.toContain('ui-opted');
758
+ });
759
+
760
+ test('does NOT admit a .tsx without the opt-in even when the split is active', async () => {
761
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 }, 'http://localhost:9');
762
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'plain.tsx'), 'export default () => null;');
763
+ // sidecar present but syncable:false → still refused.
764
+ writeFileSync(
765
+ join(ctx.paths.designRoot, 'ui', 'plain.meta.json'),
766
+ JSON.stringify({ syncable: false, title: 'Plain' })
767
+ );
768
+ const list = await discoverCanvases(ctx);
769
+ expect(list.map((c) => c.slug)).not.toContain('ui-plain');
770
+ });
771
+ });
772
+
773
+ describe('scanCanvases', () => {
774
+ test('tallies .tsx canvases separately from syncable .html', async () => {
775
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 });
776
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '');
777
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.tsx'), '');
778
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'c.tsx'), '');
779
+
780
+ const scan = await scanCanvases(ctx);
781
+ expect(scan.canvases.map((c) => c.slug)).toEqual(['ui-a']);
782
+ expect(scan.tsxCount).toBe(2);
783
+ });
784
+ });
785
+
786
+ describe('buildNoSyncablePayload', () => {
787
+ test('TSX-only project: reason names the count + DDR-060', () => {
788
+ const p = buildNoSyncablePayload('https://h.example.com', 3, '/proj/.design');
789
+ expect(p.notSyncable).toBe(true);
790
+ expect(p.tsxCount).toBe(3);
791
+ expect(p.reason).toContain('3 TSX canvas(es)');
792
+ expect(p.reason).toContain('DDR-060');
793
+ });
794
+
795
+ test('empty project: reason reports no canvases under the design root', () => {
796
+ const p = buildNoSyncablePayload('https://h.example.com', 0, '/proj/.design');
797
+ expect(p.tsxCount).toBe(0);
798
+ expect(p.reason).toContain('no canvases found under /proj/.design');
799
+ });
800
+ });
801
+
802
+ describe('toWsUrl', () => {
803
+ test('https → wss', () => {
804
+ expect(toWsUrl('https://hub.example.com')).toBe('wss://hub.example.com');
805
+ });
806
+ test('http → ws', () => {
807
+ expect(toWsUrl('http://localhost:1234')).toBe('ws://localhost:1234');
808
+ });
809
+ test('passthrough for ws://', () => {
810
+ expect(toWsUrl('ws://localhost:1234')).toBe('ws://localhost:1234');
811
+ });
812
+ });