@1agh/maude 0.23.0 → 0.25.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/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- 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 +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -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 +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- 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 +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- 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 +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- 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-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- 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/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -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-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- 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 +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- 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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// sanitizeAnnotationSvg — A3 (DDR-060 F1 re-audit). Strips executable constructs
|
|
2
|
+
// from a persisted annotation SVG while leaving the legit presentational
|
|
3
|
+
// vocabulary (strokesToSvg: path/rect/ellipse/g/line/polyline/text) byte-intact.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { sanitizeAnnotationSvg } from '../api.ts';
|
|
8
|
+
|
|
9
|
+
// The exact shape strokesToSvg() emits — must survive sanitization unchanged.
|
|
10
|
+
const LEGIT =
|
|
11
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
12
|
+
'<path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="2" fill="none" d="M0 0 L10 10" pointer-events="stroke"/>' +
|
|
13
|
+
'<rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" fill="none" x="5" y="5" width="20" height="20"/>' +
|
|
14
|
+
'<ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" fill="none" cx="10" cy="10" rx="4" ry="4"/>' +
|
|
15
|
+
'<g data-id="a1" data-tool="arrow" stroke="#222" stroke-width="2" fill="none"><line x1="0" y1="0" x2="9" y2="9"/><polyline points="3,3 9,9 5,7" fill="#222"/></g>' +
|
|
16
|
+
'<text data-id="t1" data-tool="text" data-anchor-id="x" data-font-size="14" fill="#222" text-anchor="middle" dominant-baseline="middle">label</text>' +
|
|
17
|
+
'</svg>';
|
|
18
|
+
|
|
19
|
+
describe('sanitizeAnnotationSvg — A3', () => {
|
|
20
|
+
test('leaves the legit annotation vocabulary byte-identical (zero regression)', () => {
|
|
21
|
+
expect(sanitizeAnnotationSvg(LEGIT)).toBe(LEGIT);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('strips <script> blocks', () => {
|
|
25
|
+
const dirty = LEGIT.replace('</svg>', '<script>fetch("/_api/export")</script></svg>');
|
|
26
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
27
|
+
expect(out).not.toContain('<script');
|
|
28
|
+
expect(out).not.toContain('fetch("/_api/export")');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('strips <foreignObject> (the SVG-XSS smuggle)', () => {
|
|
32
|
+
const dirty = LEGIT.replace(
|
|
33
|
+
'</svg>',
|
|
34
|
+
'<foreignObject><div onload="x"></div></foreignObject></svg>'
|
|
35
|
+
);
|
|
36
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
37
|
+
expect(out.toLowerCase()).not.toContain('foreignobject');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('strips inline event-handler attributes (onload/onclick/…)', () => {
|
|
41
|
+
const dirty =
|
|
42
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
43
|
+
'<rect x="0" y="0" width="9" height="9" onload="alert(1)" onclick=\'evil()\'/>' +
|
|
44
|
+
'</svg>';
|
|
45
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
46
|
+
expect(out).not.toMatch(/\son\w+\s*=/i);
|
|
47
|
+
expect(out).toContain('<rect');
|
|
48
|
+
expect(out).toContain('width="9"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('strips <image>/<use> and javascript: URLs', () => {
|
|
52
|
+
const dirty =
|
|
53
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
54
|
+
'<image href="javascript:alert(1)"/><use href="#x"/>' +
|
|
55
|
+
'</svg>';
|
|
56
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
57
|
+
expect(out.toLowerCase()).not.toContain('<image');
|
|
58
|
+
expect(out.toLowerCase()).not.toContain('<use');
|
|
59
|
+
expect(out.toLowerCase()).not.toContain('javascript:');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Bypasses the F1 confirming pass found against the prior denylist — the
|
|
63
|
+
// allowlist must close all of them.
|
|
64
|
+
test('drops namespaced <svg:script> (denylist missed this)', () => {
|
|
65
|
+
const out = sanitizeAnnotationSvg(
|
|
66
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><svg:script>alert(1)</svg:script></svg>'
|
|
67
|
+
);
|
|
68
|
+
expect(out.toLowerCase()).not.toContain('<svg:script');
|
|
69
|
+
expect(out.toLowerCase()).not.toContain('script>');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('drops <style> (CSS @import / url(javascript:) exfil lane)', () => {
|
|
73
|
+
const out = sanitizeAnnotationSvg(
|
|
74
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><style>@import url("//evil")</style></svg>'
|
|
75
|
+
);
|
|
76
|
+
expect(out.toLowerCase()).not.toContain('<style');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('strips style= (CSS url(javascript:)) and entity-encoded xlink:href on survivors', () => {
|
|
80
|
+
const out = sanitizeAnnotationSvg(
|
|
81
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
82
|
+
'<rect x="0" y="0" width="9" height="9" style="fill:url(javascript:alert(1))"/>' +
|
|
83
|
+
'<text xlink:href="javascript:alert(1)">hi</text></svg>'
|
|
84
|
+
);
|
|
85
|
+
expect(out).not.toMatch(/\sstyle\s*=/i);
|
|
86
|
+
expect(out).not.toMatch(/href\s*=/i);
|
|
87
|
+
// The legit elements + safe attrs survive.
|
|
88
|
+
expect(out).toContain('<rect');
|
|
89
|
+
expect(out).toContain('width="9"');
|
|
90
|
+
expect(out).toContain('<text');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('drops <set>/<animate> animation elements (attribute-name vector)', () => {
|
|
94
|
+
const out = sanitizeAnnotationSvg(
|
|
95
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect><set attributeName="onload" to="alert(1)"/></rect></svg>'
|
|
96
|
+
);
|
|
97
|
+
expect(out.toLowerCase()).not.toContain('<set');
|
|
98
|
+
expect(out.toLowerCase()).not.toContain('<animate');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Phase 24 (DDR-067 security review) — a handler glued onto the previous
|
|
102
|
+
// attribute's closing quote with NO separating whitespace is a distinct
|
|
103
|
+
// attribute to HTML/SVG parsers; the old `\s`-anchored denylist missed it.
|
|
104
|
+
test('strips a quote-glued on*= handler (no separating whitespace)', () => {
|
|
105
|
+
const out = sanitizeAnnotationSvg(
|
|
106
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
107
|
+
'<circle cx="1" cy="1" r="2"onload="alert(document.cookie)"/>' +
|
|
108
|
+
'<rect x="0" y="0" width="9" height="9"onclick=\'evil()\'/></svg>'
|
|
109
|
+
);
|
|
110
|
+
// (a bare /on\w+=/ would false-match `data-mdcc-annotations="1"` — target
|
|
111
|
+
// the actual handler names instead)
|
|
112
|
+
expect(out).not.toMatch(/\bon(?:load|click|error|mouse\w+|focus)\s*=/i);
|
|
113
|
+
expect(out).not.toContain('alert(document.cookie)');
|
|
114
|
+
expect(out).not.toContain('evil()');
|
|
115
|
+
// The legit element + its real attributes survive intact.
|
|
116
|
+
expect(out).toContain('<circle');
|
|
117
|
+
expect(out).toContain('r="2"');
|
|
118
|
+
expect(out).toContain('<rect');
|
|
119
|
+
expect(out).toContain('width="9"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Phase 24 — the new shape/arrowhead vocabulary survives byte-intact.
|
|
123
|
+
test('keeps Phase 24 <polygon>/<circle> + data-* attrs byte-intact', () => {
|
|
124
|
+
const phase24 =
|
|
125
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
126
|
+
'<polygon data-id="pg" data-tool="polygon" stroke="#222" stroke-width="2" fill="none" data-shape="diamond" points="20,0 40,20 20,40 0,20"/>' +
|
|
127
|
+
'<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" data-start-head="circle" data-line-type="curved"><path d="M0 0 Q10 10 20 0"/><circle cx="20" cy="0" r="6" fill="#111"/></g>' +
|
|
128
|
+
'<g data-id="st" data-tool="sticky" data-r="8" data-fs="16" fill="#fce8a6" data-bold="1" data-align="center"><rect x="0" y="0" width="200" height="200" rx="8" ry="8"/><text data-sticky-body="1" x="12" y="12" font-size="16" fill="#1a1a1a" dominant-baseline="hanging">x</text></g>' +
|
|
129
|
+
'</svg>';
|
|
130
|
+
expect(sanitizeAnnotationSvg(phase24)).toBe(phase24);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -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
|
+
});
|