@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
@@ -5,7 +5,15 @@
5
5
  // honors linkedHub config, discovers canvases, wires up agents, and
6
6
  // dispatches fs events through the bus correctly.
7
7
 
8
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import {
9
+ chmodSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
9
17
  import { tmpdir } from 'node:os';
10
18
  import { join } from 'node:path';
11
19
 
@@ -13,15 +21,22 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
13
21
  import { Awareness } from 'y-protocols/awareness';
14
22
  import * as Y from 'yjs';
15
23
 
24
+ import { Y_TYPES } from '../collab/persistence.ts';
25
+ import { createRegistry } from '../collab/registry.ts';
26
+ import type { RoomCallbacks } from '../collab/room.ts';
16
27
  import type { Context, DevServerConfig } from '../context.ts';
17
28
  import { createBus } from '../context.ts';
29
+ import { createConnectionMonitor } from '../sync/connection-state.ts';
18
30
  import {
19
31
  type AwarenessRegistry,
20
32
  type SyncProvider,
33
+ buildNoSyncablePayload,
21
34
  createSyncRuntime,
22
35
  discoverCanvases,
36
+ scanCanvases,
23
37
  toWsUrl,
24
38
  } from '../sync/index.ts';
39
+ import { createSyncStatusStore } from '../sync/status.ts';
25
40
 
26
41
  let dir: string;
27
42
  let cfgPathEnv: string | undefined;
@@ -51,12 +66,15 @@ function writeHubsConfig(url: string, token: string): void {
51
66
  chmodSync(cfgPath, 0o600);
52
67
  }
53
68
 
54
- function makeCtx(linkedHub?: DevServerConfig['linkedHub']): Context {
69
+ function makeCtx(linkedHub?: DevServerConfig['linkedHub'], canvasOrigin?: string): Context {
55
70
  // Minimal Context — only the fields the sync runtime touches.
56
71
  const designRoot = join(dir, 'design');
57
72
  mkdirSync(join(designRoot, 'ui'), { recursive: true });
58
73
  mkdirSync(join(designRoot, '_comments'), { recursive: true });
59
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,
60
78
  cfg: {
61
79
  name: 'test',
62
80
  projectLabel: null,
@@ -105,8 +123,15 @@ function inMemoryProviderFactory(): {
105
123
  url: string;
106
124
  token: string;
107
125
  documentName: string;
126
+ document?: Y.Doc;
108
127
  }): SyncProvider {
109
- const local = new Y.Doc();
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();
110
135
  const peer = new Y.Doc();
111
136
  local.on('update', (update: Uint8Array, origin: unknown) => {
112
137
  if (origin === TRANSPORT) return;
@@ -124,7 +149,7 @@ function inMemoryProviderFactory(): {
124
149
  // Synced immediately for the in-memory pair.
125
150
  },
126
151
  destroy() {
127
- local.destroy();
152
+ if (ownsLocal) local.destroy();
128
153
  peer.destroy();
129
154
  },
130
155
  };
@@ -172,6 +197,41 @@ describe('createSyncRuntime', () => {
172
197
  await runtime?.stop();
173
198
  });
174
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
+
175
235
  test('adopt mode: pushes local disk state up to the hub on first sync', async () => {
176
236
  const url = 'https://hub.example.com';
177
237
  writeHubsConfig(url, 'mau_test');
@@ -245,6 +305,404 @@ describe('createSyncRuntime', () => {
245
305
  await runtime?.stop();
246
306
  expect(detachCount).toBe(2);
247
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
+ });
248
706
  });
249
707
 
250
708
  describe('discoverCanvases', () => {
@@ -270,6 +728,75 @@ describe('discoverCanvases', () => {
270
728
  const list = await discoverCanvases(ctx);
271
729
  expect(list.map((c) => c.slug)).toEqual(['ui-real']);
272
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
+ });
273
800
  });
274
801
 
275
802
  describe('toWsUrl', () => {
@@ -0,0 +1,80 @@
1
+ // Sync status store tests — Phase 9 Task 8.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import type { SyncStatusSnapshot } from '../sync/connection-state.ts';
6
+ import { type SyncStatusPayload, createSyncStatusStore } from '../sync/status.ts';
7
+
8
+ function snap(partial: Partial<SyncStatusSnapshot> = {}): SyncStatusSnapshot {
9
+ return {
10
+ state: 'online',
11
+ queuedOps: 0,
12
+ lastSyncAt: null,
13
+ offlineSince: null,
14
+ flash: null,
15
+ updatedAt: 1,
16
+ ...partial,
17
+ };
18
+ }
19
+
20
+ function makeStore() {
21
+ const writes: SyncStatusPayload[] = [];
22
+ const broadcasts: SyncStatusPayload[] = [];
23
+ const store = createSyncStatusStore({
24
+ url: 'https://hub.example.com',
25
+ canvases: 3,
26
+ write: (p) => writes.push(p),
27
+ broadcast: (p) => broadcasts.push(p),
28
+ now: () => 42,
29
+ });
30
+ return { store, writes, broadcasts };
31
+ }
32
+
33
+ describe('sync status store', () => {
34
+ test('update() writes + broadcasts the merged payload', () => {
35
+ const { store, writes, broadcasts } = makeStore();
36
+ store.update(snap({ state: 'offline', queuedOps: 2 }));
37
+ expect(writes).toHaveLength(1);
38
+ expect(broadcasts).toHaveLength(1);
39
+ expect(writes[0].state).toBe('offline');
40
+ expect(writes[0].queuedOps).toBe(2);
41
+ expect(writes[0].url).toBe('https://hub.example.com');
42
+ expect(writes[0].canvases).toBe(3);
43
+ expect(writes[0].conflicts).toEqual([]);
44
+ });
45
+
46
+ test('addConflict() appends a stamped conflict + reflects in get()', () => {
47
+ const { store } = makeStore();
48
+ store.addConflict({ slug: 'screen', kind: 'cold-start-hub-wins' });
49
+ const p = store.get();
50
+ expect(p.conflicts).toHaveLength(1);
51
+ expect(p.conflicts[0]).toEqual({ slug: 'screen', kind: 'cold-start-hub-wins', at: 42 });
52
+ });
53
+
54
+ test('conflict list is capped at maxConflicts (drops oldest)', () => {
55
+ const writes: SyncStatusPayload[] = [];
56
+ const store = createSyncStatusStore({
57
+ url: 'https://h',
58
+ canvases: 1,
59
+ write: (p) => writes.push(p),
60
+ maxConflicts: 2,
61
+ now: () => 1,
62
+ });
63
+ store.addConflict({ slug: 'a', kind: 'git-pull' });
64
+ store.addConflict({ slug: 'b', kind: 'git-pull' });
65
+ store.addConflict({ slug: 'c', kind: 'git-pull' });
66
+ const p = store.get();
67
+ expect(p.conflicts.map((c) => c.slug)).toEqual(['b', 'c']);
68
+ });
69
+
70
+ test('a throwing writer never propagates into the caller', () => {
71
+ const store = createSyncStatusStore({
72
+ url: 'https://h',
73
+ canvases: 1,
74
+ write: () => {
75
+ throw new Error('disk full');
76
+ },
77
+ });
78
+ expect(() => store.update(snap({ state: 'offline' }))).not.toThrow();
79
+ });
80
+ });