@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.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Phase 9.2 (DDR-064) Phase F — convergence + stress property suite (Task 12).
|
|
2
|
+
//
|
|
3
|
+
// The load-bearing gate for the refactor (the no-break-exhaustive-verify bar):
|
|
4
|
+
// once every change flows through ONE shared Y.Doc, convergence must hold BY
|
|
5
|
+
// CONSTRUCTION. These tests pin the CRDT properties on the composed canvas doc
|
|
6
|
+
// (all five synced types) + the diff-importer + the disk projection:
|
|
7
|
+
//
|
|
8
|
+
// - commutativity — concurrent edits merge to the same state regardless of
|
|
9
|
+
// delivery order;
|
|
10
|
+
// - idempotency — re-delivering an update changes nothing;
|
|
11
|
+
// - round-trip laws — materialize∘import is stable (id on the canonical view);
|
|
12
|
+
// a projection's import∘materialize is a no-op (echo drop);
|
|
13
|
+
// - N-peer stress — N docs + a randomized-delivery relay + a file-importer,
|
|
14
|
+
// seeded RNG, → all replicas materialize byte-identical.
|
|
15
|
+
//
|
|
16
|
+
// In-process (no hub process): a relay models Hocuspocus by re-broadcasting each
|
|
17
|
+
// doc update to every other peer (Yjs updates are commutative/idempotent, so a
|
|
18
|
+
// re-broadcast relay IS the hub's convergence contract). The real-hub N-peer
|
|
19
|
+
// soak lives in plugins/design/hub/test/stress-integration.test.mjs + the live
|
|
20
|
+
// cross-machine manual run; this suite is the deterministic property gate.
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
import * as Y from 'yjs';
|
|
24
|
+
|
|
25
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
26
|
+
import {
|
|
27
|
+
Y_SYNC_TYPES,
|
|
28
|
+
applyCommentsToDoc,
|
|
29
|
+
applyCssToDoc,
|
|
30
|
+
applyHtmlToDoc,
|
|
31
|
+
applyMetaToDoc,
|
|
32
|
+
} from '../sync/codec.ts';
|
|
33
|
+
import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
|
|
34
|
+
import { materialize, materializeCanonical } from '../sync/materialize.ts';
|
|
35
|
+
import { createDocProjection } from '../sync/projection.ts';
|
|
36
|
+
|
|
37
|
+
const enc = (s: string) => new TextEncoder().encode(s);
|
|
38
|
+
|
|
39
|
+
/** Deterministic PRNG (mulberry32) so the stress is reproducible across runs. */
|
|
40
|
+
function mulberry32(seed: number): () => number {
|
|
41
|
+
let a = seed >>> 0;
|
|
42
|
+
return () => {
|
|
43
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
44
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
45
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
46
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TRANSPORT = Symbol('relay-transport');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A queued, randomized-delivery relay over N docs that models a hub. Each doc's
|
|
54
|
+
* non-transport updates are queued; flush() delivers them to every OTHER doc in
|
|
55
|
+
* a seeded-random order (and re-delivers a fraction, exercising idempotency).
|
|
56
|
+
*/
|
|
57
|
+
function makeRelay(docs: Y.Doc[], rnd: () => number) {
|
|
58
|
+
const queue: Array<{ from: number; update: Uint8Array }> = [];
|
|
59
|
+
docs.forEach((doc, i) => {
|
|
60
|
+
doc.on('update', (update: Uint8Array, origin: unknown) => {
|
|
61
|
+
if (origin === TRANSPORT) return;
|
|
62
|
+
queue.push({ from: i, update });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
flush() {
|
|
67
|
+
// Drain in waves until quiescent (a delivery can enqueue nothing new since
|
|
68
|
+
// applies are TRANSPORT-origin, so one shuffled pass suffices — but loop to
|
|
69
|
+
// be safe against any future re-entrancy).
|
|
70
|
+
let guard = 0;
|
|
71
|
+
while (queue.length > 0 && guard++ < 1000) {
|
|
72
|
+
const batch = queue.splice(0, queue.length);
|
|
73
|
+
// Shuffle (seeded) → out-of-order delivery tests commutativity.
|
|
74
|
+
for (let i = batch.length - 1; i > 0; i--) {
|
|
75
|
+
const j = Math.floor(rnd() * (i + 1));
|
|
76
|
+
[batch[i], batch[j]] = [batch[j], batch[i]];
|
|
77
|
+
}
|
|
78
|
+
for (const { from, update } of batch) {
|
|
79
|
+
docs.forEach((doc, i) => {
|
|
80
|
+
if (i === from) return;
|
|
81
|
+
Y.applyUpdate(doc, update, TRANSPORT);
|
|
82
|
+
// Idempotency: ~25% of deliveries are applied a second time.
|
|
83
|
+
if (rnd() < 0.25) Y.applyUpdate(doc, update, TRANSPORT);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('convergence laws on the composed shared doc', () => {
|
|
92
|
+
test('commutativity — concurrent edits merge identically regardless of order', () => {
|
|
93
|
+
// Peer A adds a comment; peer B sets an annotation; peer C edits the body.
|
|
94
|
+
const a = new Y.Doc();
|
|
95
|
+
const b = new Y.Doc();
|
|
96
|
+
const c = new Y.Doc();
|
|
97
|
+
const relay = makeRelay([a, b, c], mulberry32(1));
|
|
98
|
+
|
|
99
|
+
a.getArray(Y_TYPES.comments).push([{ id: 'c1', text: 'from A' }]);
|
|
100
|
+
b.getMap(Y_TYPES.annotations).set('svg', '<svg><rect/></svg>');
|
|
101
|
+
c.getText(Y_SYNC_TYPES.html).insert(0, '<main>from C</main>');
|
|
102
|
+
relay.flush();
|
|
103
|
+
|
|
104
|
+
// All three converge to the SAME canonical materialization.
|
|
105
|
+
expect(materializeCanonical(a)).toBe(materializeCanonical(b));
|
|
106
|
+
expect(materializeCanonical(b)).toBe(materializeCanonical(c));
|
|
107
|
+
// …and all three edits survived (no clobber).
|
|
108
|
+
const m = materialize(a);
|
|
109
|
+
expect(m.comments).toEqual([{ id: 'c1', text: 'from A' }]);
|
|
110
|
+
expect(m.annotations).toBe('<svg><rect/></svg>');
|
|
111
|
+
expect(m.html).toBe('<main>from C</main>');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('idempotency — re-delivering updates does not duplicate or diverge', () => {
|
|
115
|
+
const a = new Y.Doc();
|
|
116
|
+
const b = new Y.Doc();
|
|
117
|
+
// Relay re-delivers a fraction of updates (see makeRelay) — comments must
|
|
118
|
+
// not duplicate, state must not drift.
|
|
119
|
+
const relay = makeRelay([a, b], mulberry32(42));
|
|
120
|
+
a.getArray(Y_TYPES.comments).push([{ id: 'c1' }, { id: 'c2' }]);
|
|
121
|
+
relay.flush();
|
|
122
|
+
relay.flush(); // flush again — nothing queued, no-op
|
|
123
|
+
expect(materializeCanonical(a)).toBe(materializeCanonical(b));
|
|
124
|
+
expect(materialize(b).comments).toEqual([{ id: 'c1' }, { id: 'c2' }]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('round-trip laws', () => {
|
|
129
|
+
test('materialize∘import is stable (id on the canonical view)', () => {
|
|
130
|
+
// Build a doc via the codecs (the file→doc import path), materialize it,
|
|
131
|
+
// re-import each piece into a fresh doc → identical canonical view.
|
|
132
|
+
const src = new Y.Doc();
|
|
133
|
+
applyHtmlToDoc(src, '<main>body</main>', 'x');
|
|
134
|
+
applyCssToDoc(src, '.a{color:red}', 'x');
|
|
135
|
+
applyMetaToDoc(src, JSON.stringify({ title: 'T', viewport: { x: 1 } }), 'x');
|
|
136
|
+
applyCommentsToDoc(src, [{ id: 'c1', text: 'hi' }], 'x');
|
|
137
|
+
src.getMap(Y_TYPES.annotations).set('svg', '<svg/>');
|
|
138
|
+
|
|
139
|
+
const m = materialize(src);
|
|
140
|
+
const dst = new Y.Doc();
|
|
141
|
+
applyHtmlToDoc(dst, m.html, 'y');
|
|
142
|
+
applyCssToDoc(dst, m.css, 'y');
|
|
143
|
+
// meta materializes to the SHARED canonical (viewport already stripped), so
|
|
144
|
+
// re-importing it is a no-op-equivalent round-trip.
|
|
145
|
+
dst.getText(Y_SYNC_TYPES.meta).insert(0, m.meta);
|
|
146
|
+
applyCommentsToDoc(dst, m.comments, 'y');
|
|
147
|
+
dst.getMap(Y_TYPES.annotations).set('svg', m.annotations);
|
|
148
|
+
|
|
149
|
+
expect(materializeCanonical(dst)).toBe(materializeCanonical(src));
|
|
150
|
+
// meta lost its per-user viewport on the way into the doc (shared subset).
|
|
151
|
+
expect(m.meta).not.toContain('viewport');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('projection import∘materialize is a no-op (the echo is dropped)', async () => {
|
|
155
|
+
const doc = new Y.Doc();
|
|
156
|
+
const echoGuard = createEchoGuard();
|
|
157
|
+
const files: Record<string, string> = {};
|
|
158
|
+
const paths = {
|
|
159
|
+
html: '/d/x.html',
|
|
160
|
+
comments: '/d/x.comments.json',
|
|
161
|
+
annotations: '/d/x.svg',
|
|
162
|
+
meta: '/d/x.meta.json',
|
|
163
|
+
css: '/d/x.css',
|
|
164
|
+
};
|
|
165
|
+
const proj = createDocProjection({
|
|
166
|
+
slug: 'x',
|
|
167
|
+
doc,
|
|
168
|
+
paths,
|
|
169
|
+
echoGuard,
|
|
170
|
+
flushMs: 0,
|
|
171
|
+
writer: (p, bytes) => {
|
|
172
|
+
files[p] = bytes.toString();
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
proj.start();
|
|
176
|
+
doc.getText(Y_SYNC_TYPES.html).insert(0, '<main>v1</main>');
|
|
177
|
+
await proj.flush(); // materialize → files[html] written + echo hash recorded
|
|
178
|
+
|
|
179
|
+
// Now feed the just-written file back in (the fs.watch echo). The hash
|
|
180
|
+
// matches → dropped → doc unchanged (import∘materialize == noop).
|
|
181
|
+
let updates = 0;
|
|
182
|
+
doc.on('update', () => {
|
|
183
|
+
updates++;
|
|
184
|
+
});
|
|
185
|
+
const body = files[paths.html];
|
|
186
|
+
expect(proj.applyFromFs({ path: paths.html, bytes: enc(body), hash: hashBytes(body) })).toBe(
|
|
187
|
+
false
|
|
188
|
+
);
|
|
189
|
+
expect(updates).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('N-peer stress (randomized delivery + file-importer, seeded)', () => {
|
|
194
|
+
// Run a handful of seeds so the property is exercised across orderings.
|
|
195
|
+
for (const seed of [1, 7, 99, 2024]) {
|
|
196
|
+
test(`5 peers converge byte-identical (seed ${seed})`, () => {
|
|
197
|
+
const N = 5;
|
|
198
|
+
const docs = Array.from({ length: N }, () => new Y.Doc());
|
|
199
|
+
const rnd = mulberry32(seed);
|
|
200
|
+
const relay = makeRelay(docs, rnd);
|
|
201
|
+
|
|
202
|
+
// Each peer has a projection so a subset of ops are file→doc imports
|
|
203
|
+
// (html diff-import, the realistic /design:edit path). Comments/annotations
|
|
204
|
+
// are granular Y-ops (the browser path). All are CRDT-mergeable.
|
|
205
|
+
const projections = docs.map((doc, i) =>
|
|
206
|
+
createDocProjection({
|
|
207
|
+
slug: `p${i}`,
|
|
208
|
+
doc,
|
|
209
|
+
paths: {
|
|
210
|
+
html: `/d/p${i}.html`,
|
|
211
|
+
comments: `/d/p${i}.json`,
|
|
212
|
+
annotations: `/d/p${i}.svg`,
|
|
213
|
+
},
|
|
214
|
+
echoGuard: createEchoGuard(),
|
|
215
|
+
flushMs: 0,
|
|
216
|
+
// No-op writer: this stress asserts DOC convergence, not disk; the
|
|
217
|
+
// paths are synthetic, so skip real fs writes (avoids EROFS noise).
|
|
218
|
+
writer: () => {},
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
for (const p of projections) p.start();
|
|
222
|
+
|
|
223
|
+
const OPS = 60;
|
|
224
|
+
for (let k = 0; k < OPS; k++) {
|
|
225
|
+
const peer = Math.floor(rnd() * N);
|
|
226
|
+
const doc = docs[peer];
|
|
227
|
+
const r = rnd();
|
|
228
|
+
if (r < 0.4) {
|
|
229
|
+
// browser: append a comment (unique id → all survive the merge)
|
|
230
|
+
doc.getArray(Y_TYPES.comments).push([{ id: `c-${peer}-${k}`, text: `op${k}` }]);
|
|
231
|
+
} else if (r < 0.6) {
|
|
232
|
+
// browser: set the annotation key (LWW — converges to one value)
|
|
233
|
+
doc.getMap(Y_TYPES.annotations).set('svg', `<svg data-k="${k}"/>`);
|
|
234
|
+
} else if (r < 0.8) {
|
|
235
|
+
// browser: insert into the body text CRDT
|
|
236
|
+
const t = doc.getText(Y_SYNC_TYPES.html);
|
|
237
|
+
t.insert(Math.min(t.length, Math.floor(rnd() * (t.length + 1))), `<i>${k}</i>`);
|
|
238
|
+
} else {
|
|
239
|
+
// /design:edit: a whole-file body import via the projection (diff)
|
|
240
|
+
const body = `<main>edit ${peer}/${k}</main>`;
|
|
241
|
+
projections[peer].applyFromFs({
|
|
242
|
+
path: `/d/p${peer}.html`,
|
|
243
|
+
bytes: enc(body),
|
|
244
|
+
hash: hashBytes(body),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Flush intermittently (partial delivery) to interleave ordering.
|
|
248
|
+
if (rnd() < 0.5) relay.flush();
|
|
249
|
+
}
|
|
250
|
+
relay.flush(); // final quiescence
|
|
251
|
+
|
|
252
|
+
// Every replica converged to byte-identical canonical state.
|
|
253
|
+
const ref = materializeCanonical(docs[0]);
|
|
254
|
+
for (let i = 1; i < N; i++) {
|
|
255
|
+
expect(materializeCanonical(docs[i])).toBe(ref);
|
|
256
|
+
}
|
|
257
|
+
// And every comment that was appended survived (no clobber, no dup).
|
|
258
|
+
const commentIds = new Set(
|
|
259
|
+
(materialize(docs[0]).comments as Array<{ id: string }>).map((c) => c.id)
|
|
260
|
+
);
|
|
261
|
+
// At least one comment landed; ids are unique by construction.
|
|
262
|
+
expect(commentIds.size).toBe(materialize(docs[0]).comments.length);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Phase 9.2 (DDR-064) Phase A — single-shared-doc foundation.
|
|
2
|
+
//
|
|
3
|
+
// Phase A is pure plumbing: the registry exposes one cached Y.Doc per slug
|
|
4
|
+
// (the seam the hub provider attaches to in Phase B) and the MAUDE_SHARED_DOC
|
|
5
|
+
// flag is threaded onto Context (default OFF = the two-doc path = zero
|
|
6
|
+
// regression). These tests pin the Task 2 contract + the Risk 2 mitigation
|
|
7
|
+
// (a single yjs instance — "imported twice" silently breaks sync).
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import * as Y from 'yjs';
|
|
11
|
+
|
|
12
|
+
import { createRegistry } from '../collab/registry.ts';
|
|
13
|
+
import type { RoomCallbacks } from '../collab/room.ts';
|
|
14
|
+
|
|
15
|
+
function noopCallbacks(): RoomCallbacks {
|
|
16
|
+
return {
|
|
17
|
+
async seed() {},
|
|
18
|
+
async persistJson() {},
|
|
19
|
+
async persistBinary() {},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Phase A — registry.getDoc (single shared doc per slug)', () => {
|
|
24
|
+
test('getDoc caches: repeated calls return the SAME Y.Doc instance', () => {
|
|
25
|
+
const r = createRegistry(noopCallbacks());
|
|
26
|
+
const a = r.getDoc('canvas-x');
|
|
27
|
+
const b = r.getDoc('canvas-x');
|
|
28
|
+
expect(a).toBe(b);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('getDoc returns the owning room.doc (single source of truth)', () => {
|
|
32
|
+
const r = createRegistry(noopCallbacks());
|
|
33
|
+
const doc = r.getDoc('canvas-x');
|
|
34
|
+
// get() is get-or-create; it must reuse the room getDoc just created, so
|
|
35
|
+
// the doc the browser room syncs IS the doc the provider would attach to.
|
|
36
|
+
expect(r.get('canvas-x').doc).toBe(doc);
|
|
37
|
+
expect(r.peek('canvas-x')?.doc).toBe(doc);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('distinct slugs get distinct docs', () => {
|
|
41
|
+
const r = createRegistry(noopCallbacks());
|
|
42
|
+
expect(r.getDoc('a')).not.toBe(r.getDoc('b'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Risk 2 — getDoc yields a real Y.Doc from the single yjs instance', () => {
|
|
46
|
+
// If room.ts ever resolved a SECOND copy of yjs, this instanceof against
|
|
47
|
+
// the test's own import would be false and live sync would silently break.
|
|
48
|
+
const r = createRegistry(noopCallbacks());
|
|
49
|
+
expect(r.getDoc('canvas-x')).toBeInstanceOf(Y.Doc);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('getDoc creates the room before any browser connects (provider can attach at serve start)', () => {
|
|
53
|
+
const r = createRegistry(noopCallbacks());
|
|
54
|
+
expect(r.peek('canvas-x')).toBeNull(); // nothing created yet
|
|
55
|
+
const doc = r.getDoc('canvas-x');
|
|
56
|
+
// The room now exists with zero connections — exactly the state the hub
|
|
57
|
+
// provider attaches into at serve start (Phase B gotcha).
|
|
58
|
+
const room = r.peek('canvas-x');
|
|
59
|
+
expect(room).not.toBeNull();
|
|
60
|
+
expect(room?.size()).toBe(0);
|
|
61
|
+
expect(room?.doc).toBe(doc);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Phase 9.2 (DDR-064) Phase E — the one-time authoritative seed (Task 9).
|
|
2
|
+
//
|
|
3
|
+
// Pins the fix for Risk 1, the duplication-on-merge trap: a shared doc must be
|
|
4
|
+
// populated from ONE source. These tests prove migrateSeed never duplicates
|
|
5
|
+
// comments and is idempotent across re-runs (server restarts).
|
|
6
|
+
|
|
7
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
+
import * as Y from 'yjs';
|
|
13
|
+
|
|
14
|
+
import { Y_TYPES, createPersistence } from '../collab/persistence.ts';
|
|
15
|
+
import type { Context } from '../context.ts';
|
|
16
|
+
import { applyCommentsToDoc, applyHtmlToDoc } from '../sync/codec.ts';
|
|
17
|
+
import { docIsEmpty, migrateSeed } from '../sync/migrate-seed.ts';
|
|
18
|
+
|
|
19
|
+
let dir: string;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), 'migrate-seed-'));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function paths() {
|
|
28
|
+
return {
|
|
29
|
+
html: join(dir, 'screen.html'),
|
|
30
|
+
comments: join(dir, 'screen.comments.json'),
|
|
31
|
+
annotations: join(dir, 'screen.annotations.svg'),
|
|
32
|
+
meta: join(dir, 'screen.meta.json'),
|
|
33
|
+
css: join(dir, 'screen.css'),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('docIsEmpty', () => {
|
|
38
|
+
test('true for a fresh doc, false once any type is populated', () => {
|
|
39
|
+
const doc = new Y.Doc();
|
|
40
|
+
expect(docIsEmpty(doc)).toBe(true);
|
|
41
|
+
doc.getArray(Y_TYPES.comments).push([{ id: 'c1' }]);
|
|
42
|
+
expect(docIsEmpty(doc)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('migrateSeed — adopt (hub was empty)', () => {
|
|
47
|
+
test('seeds an empty doc from local files and reports local-adopt', () => {
|
|
48
|
+
const p = paths();
|
|
49
|
+
writeFileSync(p.html, '<main>local body</main>');
|
|
50
|
+
writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'local' }]));
|
|
51
|
+
|
|
52
|
+
const doc = new Y.Doc();
|
|
53
|
+
const result = migrateSeed({ slug: 's', doc, paths: p });
|
|
54
|
+
|
|
55
|
+
expect(result).toBe('local-adopt');
|
|
56
|
+
expect(doc.getText('html').toString()).toBe('<main>local body</main>');
|
|
57
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'local' }]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('empty doc + no local files → "empty" (nothing to seed)', () => {
|
|
61
|
+
const doc = new Y.Doc();
|
|
62
|
+
expect(migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
|
|
63
|
+
expect(docIsEmpty(doc)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('migrateSeed — idempotent (no duplication on re-run)', () => {
|
|
68
|
+
test('adopting twice leaves comments un-duplicated', () => {
|
|
69
|
+
const p = paths();
|
|
70
|
+
writeFileSync(
|
|
71
|
+
p.comments,
|
|
72
|
+
JSON.stringify([
|
|
73
|
+
{ id: 'c1', text: 'x' },
|
|
74
|
+
{ id: 'c2', text: 'y' },
|
|
75
|
+
])
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const doc = new Y.Doc();
|
|
79
|
+
expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
|
|
80
|
+
expect(doc.getArray(Y_TYPES.comments).length).toBe(2);
|
|
81
|
+
|
|
82
|
+
// Second run (e.g. server restart): the doc is now non-empty → hub-wins
|
|
83
|
+
// branch → no re-adopt → still exactly 2 comments, byte-identical.
|
|
84
|
+
expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
|
|
85
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
|
|
86
|
+
{ id: 'c1', text: 'x' },
|
|
87
|
+
{ id: 'c2', text: 'y' },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('room file-seed disable (the other half of the Risk-1 fix)', () => {
|
|
93
|
+
test('persistence.seed is a no-op for a pinned slug — never touches api or the doc', async () => {
|
|
94
|
+
let apiCalled = false;
|
|
95
|
+
// Minimal api stub; seed must short-circuit before reaching it.
|
|
96
|
+
const api = {
|
|
97
|
+
loadCommentsForFile: async () => {
|
|
98
|
+
apiCalled = true;
|
|
99
|
+
return [];
|
|
100
|
+
},
|
|
101
|
+
loadAnnotations: async () => {
|
|
102
|
+
apiCalled = true;
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
105
|
+
} as unknown as import('../api.ts').Api;
|
|
106
|
+
const ctx = { paths: { designRoot: dir } } as unknown as Context;
|
|
107
|
+
|
|
108
|
+
const persistence = createPersistence({
|
|
109
|
+
ctx,
|
|
110
|
+
api,
|
|
111
|
+
fileForSlug: async () => 'ui/x.html',
|
|
112
|
+
// Mirrors createCollab's predicate: don't seed a pinned (provider-attached)
|
|
113
|
+
// slug under sharedDoc — the migrate-seed + provider own population.
|
|
114
|
+
shouldSeed: (slug) => slug !== 'pinned',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const doc = new Y.Doc();
|
|
118
|
+
await persistence.seed('pinned', doc);
|
|
119
|
+
// Returned before touching the api or mutating the doc → no duplicate items
|
|
120
|
+
// can be introduced by the file-seed after the migrate-seed ran.
|
|
121
|
+
expect(apiCalled).toBe(false);
|
|
122
|
+
expect(docIsEmpty(doc)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
127
|
+
test('a doc already holding hub comments is NOT merged with the local file → no dup', () => {
|
|
128
|
+
const p = paths();
|
|
129
|
+
// Local file (e.g. copied via git) holds the SAME logical comment id…
|
|
130
|
+
writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'from disk' }]));
|
|
131
|
+
|
|
132
|
+
// …but the doc already received it from the hub (a DIFFERENT CRDT item).
|
|
133
|
+
const doc = new Y.Doc();
|
|
134
|
+
applyCommentsToDoc(doc, [{ id: 'c1', text: 'from hub' }], 'hub');
|
|
135
|
+
|
|
136
|
+
const result = migrateSeed({ slug: 's', doc, paths: p });
|
|
137
|
+
|
|
138
|
+
// Hub wins; the local file is NOT pushed as a second item — exactly ONE c1.
|
|
139
|
+
expect(result).toBe('hub-wins');
|
|
140
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'from hub' }]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('snapshots divergent local files to historyDir before the hub-wins overwrite', () => {
|
|
144
|
+
const p = paths();
|
|
145
|
+
writeFileSync(p.html, '<main>LOCAL divergent</main>');
|
|
146
|
+
writeFileSync(p.comments, JSON.stringify([{ id: 'local' }]));
|
|
147
|
+
|
|
148
|
+
const doc = new Y.Doc();
|
|
149
|
+
applyHtmlToDoc(doc, '<main>HUB</main>', 'hub'); // doc holds hub state
|
|
150
|
+
|
|
151
|
+
const historyDir = join(dir, '_history', 's');
|
|
152
|
+
const result = migrateSeed({ slug: 's', doc, paths: p, historyDir });
|
|
153
|
+
|
|
154
|
+
expect(result).toBe('hub-wins');
|
|
155
|
+
// The pre-migration snapshot captured the local files for rollback.
|
|
156
|
+
const snapped = readdirSync(join(historyDir, 'pre-shared-doc-migration'));
|
|
157
|
+
expect(snapped).toContain('screen.html');
|
|
158
|
+
expect(snapped).toContain('screen.comments.json');
|
|
159
|
+
});
|
|
160
|
+
});
|