@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,165 @@
1
+ // Codec unit tests — Phase 9 Task 4.
2
+ //
3
+ // Verify Y.Doc <-> disk round-trips for the three classes of files the sync
4
+ // agent shuttles: HTML body, comments JSON, annotations SVG.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Y from 'yjs';
8
+
9
+ import { Y_TYPES } from '../collab/persistence.ts';
10
+ import {
11
+ Y_SYNC_TYPES,
12
+ annotationsFromDoc,
13
+ applyAnnotationsToDoc,
14
+ applyCommentsToDoc,
15
+ applyHtmlToDoc,
16
+ commentsFromDoc,
17
+ htmlFromDoc,
18
+ } from '../sync/codec.ts';
19
+
20
+ describe('HTML codec', () => {
21
+ test('htmlFromDoc returns empty string for a fresh doc', () => {
22
+ const doc = new Y.Doc();
23
+ expect(htmlFromDoc(doc)).toBe('');
24
+ });
25
+
26
+ test('applyHtmlToDoc → htmlFromDoc round-trip', () => {
27
+ const doc = new Y.Doc();
28
+ applyHtmlToDoc(doc, '<button>hello</button>');
29
+ expect(htmlFromDoc(doc)).toBe('<button>hello</button>');
30
+ });
31
+
32
+ test('applyHtmlToDoc with identical content is a no-op', () => {
33
+ const doc = new Y.Doc();
34
+ applyHtmlToDoc(doc, '<button>hi</button>');
35
+ let updates = 0;
36
+ doc.on('update', () => {
37
+ updates++;
38
+ });
39
+ const changed = applyHtmlToDoc(doc, '<button>hi</button>');
40
+ expect(changed).toBe(false);
41
+ expect(updates).toBe(0);
42
+ });
43
+
44
+ test('applyHtmlToDoc emits a minimal op via common prefix + suffix', () => {
45
+ const doc = new Y.Doc();
46
+ applyHtmlToDoc(doc, '<button class="cta">Click</button>');
47
+
48
+ // Yjs requires `event.changes.delta` to be read inside the observer.
49
+ let inserts = '';
50
+ let deletes = 0;
51
+ const yText = doc.getText(Y_SYNC_TYPES.html);
52
+ yText.observe((evt) => {
53
+ for (const d of evt.changes.delta) {
54
+ if (typeof d.insert === 'string') inserts += d.insert;
55
+ if (typeof d.delete === 'number') deletes += d.delete;
56
+ }
57
+ });
58
+
59
+ applyHtmlToDoc(doc, '<button class="cta">Tap</button>');
60
+ expect(htmlFromDoc(doc)).toBe('<button class="cta">Tap</button>');
61
+
62
+ // The change is "Click" → "Tap". With shared prefix
63
+ // `<button class="cta">` and shared suffix `</button>`, the delta should
64
+ // only touch the middle.
65
+ expect(inserts).toBe('Tap');
66
+ expect(deletes).toBe('Click'.length);
67
+ });
68
+
69
+ test('applyHtmlToDoc handles complete replace (no shared prefix)', () => {
70
+ const doc = new Y.Doc();
71
+ applyHtmlToDoc(doc, 'aaaa');
72
+ applyHtmlToDoc(doc, 'bbbb');
73
+ expect(htmlFromDoc(doc)).toBe('bbbb');
74
+ });
75
+
76
+ test('applyHtmlToDoc tags transaction with origin', () => {
77
+ const doc = new Y.Doc();
78
+ applyHtmlToDoc(doc, 'seed');
79
+
80
+ let observedOrigin: unknown = 'unset';
81
+ doc.on('update', (_update: Uint8Array, origin: unknown) => {
82
+ observedOrigin = origin;
83
+ });
84
+
85
+ const myOrigin = { id: 'sync-agent' };
86
+ applyHtmlToDoc(doc, 'changed', myOrigin);
87
+ expect(observedOrigin).toBe(myOrigin);
88
+ });
89
+ });
90
+
91
+ describe('Comments codec', () => {
92
+ test('commentsFromDoc returns empty array for a fresh doc', () => {
93
+ expect(commentsFromDoc(new Y.Doc())).toEqual([]);
94
+ });
95
+
96
+ test('applyCommentsToDoc → commentsFromDoc round-trip', () => {
97
+ const doc = new Y.Doc();
98
+ const snap = [{ id: 'c1', body: 'hi' }];
99
+ applyCommentsToDoc(doc, snap);
100
+ expect(commentsFromDoc(doc)).toEqual(snap);
101
+ });
102
+
103
+ test('applyCommentsToDoc with identical content is a no-op', () => {
104
+ const doc = new Y.Doc();
105
+ applyCommentsToDoc(doc, [{ id: 'c1' }]);
106
+ let updates = 0;
107
+ doc.on('update', () => {
108
+ updates++;
109
+ });
110
+ const changed = applyCommentsToDoc(doc, [{ id: 'c1' }]);
111
+ expect(changed).toBe(false);
112
+ expect(updates).toBe(0);
113
+ });
114
+
115
+ test('applyCommentsToDoc replaces existing entries on disk-wins update', () => {
116
+ const doc = new Y.Doc();
117
+ applyCommentsToDoc(doc, [{ id: 'c1' }, { id: 'c2' }]);
118
+ applyCommentsToDoc(doc, [{ id: 'c3' }]);
119
+ expect(commentsFromDoc(doc)).toEqual([{ id: 'c3' }]);
120
+ });
121
+
122
+ test('comments share Y_TYPES.comments name with Phase 6 persistence', () => {
123
+ const doc = new Y.Doc();
124
+ applyCommentsToDoc(doc, [{ id: 'c1' }]);
125
+ // The Phase 6 persistence layer reads from this name directly.
126
+ expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1' }]);
127
+ });
128
+ });
129
+
130
+ describe('Annotations codec', () => {
131
+ test('annotationsFromDoc returns null for a fresh doc', () => {
132
+ expect(annotationsFromDoc(new Y.Doc())).toBeNull();
133
+ });
134
+
135
+ test('applyAnnotationsToDoc → annotationsFromDoc round-trip', () => {
136
+ const doc = new Y.Doc();
137
+ applyAnnotationsToDoc(doc, '<svg></svg>');
138
+ expect(annotationsFromDoc(doc)).toBe('<svg></svg>');
139
+ });
140
+
141
+ test('applyAnnotationsToDoc with null clears the entry', () => {
142
+ const doc = new Y.Doc();
143
+ applyAnnotationsToDoc(doc, '<svg></svg>');
144
+ applyAnnotationsToDoc(doc, null);
145
+ expect(annotationsFromDoc(doc)).toBeNull();
146
+ });
147
+
148
+ test('applyAnnotationsToDoc with identical content is a no-op', () => {
149
+ const doc = new Y.Doc();
150
+ applyAnnotationsToDoc(doc, '<svg></svg>');
151
+ let updates = 0;
152
+ doc.on('update', () => {
153
+ updates++;
154
+ });
155
+ const changed = applyAnnotationsToDoc(doc, '<svg></svg>');
156
+ expect(changed).toBe(false);
157
+ expect(updates).toBe(0);
158
+ });
159
+
160
+ test('annotations share Y_TYPES.annotations name with Phase 5 persistence', () => {
161
+ const doc = new Y.Doc();
162
+ applyAnnotationsToDoc(doc, '<svg>x</svg>');
163
+ expect(doc.getMap<string>(Y_TYPES.annotations).get('svg')).toBe('<svg>x</svg>');
164
+ });
165
+ });
@@ -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,96 @@
1
+ // Echo-guard unit tests — Phase 9 Task 4.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { ECHO_TTL_MS, createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
6
+
7
+ describe('hashBytes', () => {
8
+ test('stable hex SHA-256 of bytes', () => {
9
+ expect(hashBytes('hello')).toBe(
10
+ '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
11
+ );
12
+ });
13
+
14
+ test('matches across string + Uint8Array inputs', () => {
15
+ const buf = new TextEncoder().encode('hello');
16
+ expect(hashBytes(buf)).toBe(hashBytes('hello'));
17
+ });
18
+ });
19
+
20
+ describe('createEchoGuard', () => {
21
+ test('record + consume same hash returns true (echo dropped)', () => {
22
+ const g = createEchoGuard();
23
+ g.record('a.html', 'h1');
24
+ expect(g.consume('a.html', 'h1')).toBe(true);
25
+ // Entry was popped — second consume of the same hash is a fresh match.
26
+ expect(g.consume('a.html', 'h1')).toBe(false);
27
+ });
28
+
29
+ test('consume with non-matching hash returns false (real edit)', () => {
30
+ const g = createEchoGuard();
31
+ g.record('a.html', 'h1');
32
+ expect(g.consume('a.html', 'different-hash')).toBe(false);
33
+ // Original entry stays available for matching.
34
+ expect(g.consume('a.html', 'h1')).toBe(true);
35
+ });
36
+
37
+ test('consume on path with no pending entries returns false', () => {
38
+ const g = createEchoGuard();
39
+ expect(g.consume('a.html', 'whatever')).toBe(false);
40
+ });
41
+
42
+ test('expires entries past TTL', () => {
43
+ const g = createEchoGuard(100);
44
+ g.record('a.html', 'h1', 0);
45
+ // Same instant — still valid.
46
+ expect(g.consume('a.html', 'h1', 50)).toBe(true);
47
+
48
+ g.record('a.html', 'h2', 0);
49
+ // Past TTL — expired, not consumed.
50
+ expect(g.consume('a.html', 'h2', 200)).toBe(false);
51
+ });
52
+
53
+ test('per-path isolation', () => {
54
+ const g = createEchoGuard();
55
+ g.record('a.html', 'h1');
56
+ g.record('b.html', 'h2');
57
+ expect(g.consume('a.html', 'h2')).toBe(false);
58
+ expect(g.consume('b.html', 'h1')).toBe(false);
59
+ expect(g.consume('a.html', 'h1')).toBe(true);
60
+ expect(g.consume('b.html', 'h2')).toBe(true);
61
+ });
62
+
63
+ test('stacked writes — N record() calls allow N consume() matches', () => {
64
+ const g = createEchoGuard();
65
+ g.record('a.html', 'h1');
66
+ g.record('a.html', 'h1');
67
+ g.record('a.html', 'h1');
68
+ expect(g.consume('a.html', 'h1')).toBe(true);
69
+ expect(g.consume('a.html', 'h1')).toBe(true);
70
+ expect(g.consume('a.html', 'h1')).toBe(true);
71
+ expect(g.consume('a.html', 'h1')).toBe(false);
72
+ });
73
+
74
+ test('sweep removes expired entries idempotently', () => {
75
+ const g = createEchoGuard(100);
76
+ g.record('a.html', 'h1', 0);
77
+ g.record('b.html', 'h2', 0);
78
+ expect(g.size()).toBe(2);
79
+ g.sweep(50);
80
+ expect(g.size()).toBe(2);
81
+ g.sweep(200);
82
+ expect(g.size()).toBe(0);
83
+ // Double-sweep is fine.
84
+ g.sweep(300);
85
+ expect(g.size()).toBe(0);
86
+ });
87
+
88
+ test('default TTL is ECHO_TTL_MS', () => {
89
+ const g = createEchoGuard();
90
+ g.record('a.html', 'h1', 0);
91
+ expect(g.consume('a.html', 'h1', ECHO_TTL_MS - 1)).toBe(true);
92
+
93
+ g.record('a.html', 'h2', 0);
94
+ expect(g.consume('a.html', 'h2', ECHO_TTL_MS + 1)).toBe(false);
95
+ });
96
+ });
@@ -0,0 +1,182 @@
1
+ // fs-mirror unit tests — Phase 9 Task 4.
2
+
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+
9
+ import { hashBytes } from '../sync/echo-guard.ts';
10
+ import { createFsReader } from '../sync/fs-mirror.ts';
11
+
12
+ let dir: string;
13
+
14
+ beforeEach(() => {
15
+ dir = mkdtempSync(join(tmpdir(), 'fs-mirror-'));
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ const ACCEPT_ALL = () => true;
23
+
24
+ describe('createFsReader', () => {
25
+ test('reads file bytes + hash after the quiet window', async () => {
26
+ writeFileSync(join(dir, 'a.html'), 'hello');
27
+ const events: { path: string; bytes: Uint8Array; hash: string }[] = [];
28
+ const r = createFsReader({
29
+ rootDir: dir,
30
+ quietMs: 10,
31
+ accept: ACCEPT_ALL,
32
+ onRead: (e) => {
33
+ events.push(e);
34
+ },
35
+ });
36
+ r.notify('a.html');
37
+ await new Promise((res) => setTimeout(res, 50));
38
+
39
+ expect(events.length).toBe(1);
40
+ expect(events[0].path).toBe('a.html');
41
+ expect(new TextDecoder().decode(events[0].bytes)).toBe('hello');
42
+ expect(events[0].hash).toBe(hashBytes('hello'));
43
+ r.stop();
44
+ });
45
+
46
+ test('debounces rapid notify() calls on the same path', async () => {
47
+ writeFileSync(join(dir, 'b.html'), 'v0');
48
+ const events: { path: string; hash: string }[] = [];
49
+ const r = createFsReader({
50
+ rootDir: dir,
51
+ quietMs: 30,
52
+ accept: ACCEPT_ALL,
53
+ onRead: (e) => {
54
+ events.push({ path: e.path, hash: e.hash });
55
+ },
56
+ });
57
+ // 5 notify calls within 5ms should collapse to one read.
58
+ for (let i = 0; i < 5; i++) r.notify('b.html');
59
+ expect(r.pending()).toBe(1);
60
+ await new Promise((res) => setTimeout(res, 80));
61
+ expect(events.length).toBe(1);
62
+ r.stop();
63
+ });
64
+
65
+ test('reads the latest disk content after the quiet window (not stale)', async () => {
66
+ const p = join(dir, 'c.html');
67
+ writeFileSync(p, 'old');
68
+ const events: string[] = [];
69
+ const r = createFsReader({
70
+ rootDir: dir,
71
+ quietMs: 20,
72
+ accept: ACCEPT_ALL,
73
+ onRead: (e) => {
74
+ events.push(new TextDecoder().decode(e.bytes));
75
+ },
76
+ });
77
+ r.notify('c.html');
78
+ // Update the file BEFORE the quiet window elapses — the read should see
79
+ // the new content.
80
+ writeFileSync(p, 'new');
81
+ await new Promise((res) => setTimeout(res, 50));
82
+ expect(events).toEqual(['new']);
83
+ r.stop();
84
+ });
85
+
86
+ test('per-path isolation — different paths debounce independently', async () => {
87
+ writeFileSync(join(dir, 'd.html'), 'a');
88
+ writeFileSync(join(dir, 'e.html'), 'b');
89
+ const paths: string[] = [];
90
+ const r = createFsReader({
91
+ rootDir: dir,
92
+ quietMs: 10,
93
+ accept: ACCEPT_ALL,
94
+ onRead: (e) => {
95
+ paths.push(e.path);
96
+ },
97
+ });
98
+ r.notify('d.html');
99
+ r.notify('e.html');
100
+ expect(r.pending()).toBe(2);
101
+ await new Promise((res) => setTimeout(res, 50));
102
+ expect(paths.sort()).toEqual(['d.html', 'e.html']);
103
+ r.stop();
104
+ });
105
+
106
+ test('accept() filter rejects paths before any timer fires', async () => {
107
+ writeFileSync(join(dir, 'skipme.css'), 'x');
108
+ let fired = false;
109
+ const r = createFsReader({
110
+ rootDir: dir,
111
+ quietMs: 10,
112
+ accept: (p) => p.endsWith('.html'),
113
+ onRead: () => {
114
+ fired = true;
115
+ },
116
+ });
117
+ r.notify('skipme.css');
118
+ expect(r.pending()).toBe(0);
119
+ await new Promise((res) => setTimeout(res, 50));
120
+ expect(fired).toBe(false);
121
+ r.stop();
122
+ });
123
+
124
+ test('onDeleted fires when the file is gone by the time the timer pops', async () => {
125
+ writeFileSync(join(dir, 'goner.html'), 'bye');
126
+ const deleted: string[] = [];
127
+ const reads: string[] = [];
128
+ const r = createFsReader({
129
+ rootDir: dir,
130
+ quietMs: 20,
131
+ accept: ACCEPT_ALL,
132
+ onRead: (e) => {
133
+ reads.push(e.path);
134
+ },
135
+ onDeleted: (p) => {
136
+ deleted.push(p);
137
+ },
138
+ });
139
+ r.notify('goner.html');
140
+ rmSync(join(dir, 'goner.html'));
141
+ await new Promise((res) => setTimeout(res, 60));
142
+ expect(deleted).toEqual(['goner.html']);
143
+ expect(reads).toEqual([]);
144
+ r.stop();
145
+ });
146
+
147
+ test('stop() cancels pending timers', async () => {
148
+ writeFileSync(join(dir, 'h.html'), 'x');
149
+ let fired = false;
150
+ const r = createFsReader({
151
+ rootDir: dir,
152
+ quietMs: 20,
153
+ accept: ACCEPT_ALL,
154
+ onRead: () => {
155
+ fired = true;
156
+ },
157
+ });
158
+ r.notify('h.html');
159
+ r.stop();
160
+ await new Promise((res) => setTimeout(res, 50));
161
+ expect(fired).toBe(false);
162
+ });
163
+
164
+ test('flush() fires all pending timers immediately', async () => {
165
+ writeFileSync(join(dir, 'i.html'), 'x');
166
+ writeFileSync(join(dir, 'j.html'), 'y');
167
+ const reads: string[] = [];
168
+ const r = createFsReader({
169
+ rootDir: dir,
170
+ quietMs: 10000, // long enough that the test would time out without flush
171
+ accept: ACCEPT_ALL,
172
+ onRead: (e) => {
173
+ reads.push(e.path);
174
+ },
175
+ });
176
+ r.notify('i.html');
177
+ r.notify('j.html');
178
+ await r.flush();
179
+ expect(reads.sort()).toEqual(['i.html', 'j.html']);
180
+ r.stop();
181
+ });
182
+ });