@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.
- 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 +32 -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 +126 -2
- 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 +74 -10
- 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/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/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +333 -19
- package/plugins/design/dev-server/client/app.jsx +155 -7
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -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 +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +126 -13
- package/plugins/design/dev-server/dist/comment-mount.js +78 -11
- package/plugins/design/dev-server/dist/styles.css +16 -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/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +26 -3
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- 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-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- 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/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-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- 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-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-tool-mode.tsx +15 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +49 -1
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
});
|