@1agh/maude 0.29.0 → 0.30.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 +1 -1
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +12 -2
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// FigJam v3 — model schema extension: data-group-ids / data-author /
|
|
2
|
+
// data-start-bind / data-end-bind. Three load-bearing assertions:
|
|
3
|
+
// 1. the new fixture round-trips BYTE-IDENTICAL (parse → re-serialize),
|
|
4
|
+
// 2. legacy strokes (no new fields) serialize WITHOUT any new attribute
|
|
5
|
+
// (the Phase-24 canary holds — covered again here from the model side),
|
|
6
|
+
// 3. the sanitizer passes the new data-* attrs through (it denylists only
|
|
7
|
+
// on*/style/href) and poisoned bind values are rejected on parse.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type AnchorHost,
|
|
14
|
+
type ArrowStroke,
|
|
15
|
+
convertShapeKind,
|
|
16
|
+
type PolygonStroke,
|
|
17
|
+
type RectStroke,
|
|
18
|
+
type StickyStroke,
|
|
19
|
+
type Stroke,
|
|
20
|
+
shapeKindOf,
|
|
21
|
+
strokeBBox,
|
|
22
|
+
strokeHitTest,
|
|
23
|
+
strokesToSvg,
|
|
24
|
+
svgToStrokes,
|
|
25
|
+
type TextStroke,
|
|
26
|
+
} from '../annotations-model.ts';
|
|
27
|
+
import { sanitizeAnnotationSvg } from '../api.ts';
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
GlobalRegistrator.register();
|
|
31
|
+
});
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await GlobalRegistrator.unregister();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const FIXTURE_URL = new URL('./fixtures/figjam-v3-groups-bindings.svg', import.meta.url);
|
|
37
|
+
|
|
38
|
+
describe('figjam-v3 fixture canary', () => {
|
|
39
|
+
test('round-trips byte-identical (groups + nested groups + binds + author)', async () => {
|
|
40
|
+
const fixture = await Bun.file(FIXTURE_URL).text();
|
|
41
|
+
expect(strokesToSvg(svgToStrokes(fixture))).toBe(fixture);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('round-trips byte-identical THROUGH the sanitizer', async () => {
|
|
45
|
+
const fixture = await Bun.file(FIXTURE_URL).text();
|
|
46
|
+
expect(sanitizeAnnotationSvg(fixture)).toBe(fixture);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('parse recovers the new fields exactly', async () => {
|
|
50
|
+
const strokes = svgToStrokes(await Bun.file(FIXTURE_URL).text());
|
|
51
|
+
const rect = strokes.find((s) => s.id === 's_aaa1') as RectStroke;
|
|
52
|
+
expect(rect.groupIds).toEqual(['g_team1']);
|
|
53
|
+
const sticky = strokes.find((s) => s.id === 's_aaa2') as StickyStroke;
|
|
54
|
+
expect(sticky.groupIds).toEqual(['g_inner', 'g_team1']); // deepest→shallowest order kept
|
|
55
|
+
const arrow = strokes.find((s) => s.id === 's_aaa3') as ArrowStroke;
|
|
56
|
+
expect(arrow.startBind).toEqual({ hostId: 's_aaa1', nx: 1, ny: 0.5 }); // auto
|
|
57
|
+
expect(arrow.endBind).toEqual({ hostId: 's_aaa2', nx: 0, ny: 0.5, pinned: true });
|
|
58
|
+
const note = strokes.find((s) => s.id === 's_aaa4') as TextStroke;
|
|
59
|
+
expect(note.author).toBe('ai');
|
|
60
|
+
// The ellipse carries rotation but none of the other new fields.
|
|
61
|
+
const ell = strokes.find((s) => s.id === 's_aaa5') as Stroke;
|
|
62
|
+
expect(ell.rotation).toBe(15);
|
|
63
|
+
expect('groupIds' in ell).toBe(false);
|
|
64
|
+
expect('author' in ell).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('serialize-only-non-default invariant for the v3 fields', () => {
|
|
69
|
+
test('a legacy stroke set emits NO v3 attribute', () => {
|
|
70
|
+
const strokes: Stroke[] = [
|
|
71
|
+
{ id: 's_1', tool: 'rect', color: '#1f1f1f', width: 3, x: 0, y: 0, w: 10, h: 10 },
|
|
72
|
+
{ id: 's_2', tool: 'arrow', color: '#1f1f1f', width: 3, x1: 0, y1: 0, x2: 9, y2: 0 },
|
|
73
|
+
];
|
|
74
|
+
const svg = strokesToSvg(strokes);
|
|
75
|
+
expect(svg).not.toContain('data-group-ids');
|
|
76
|
+
expect(svg).not.toContain('data-author');
|
|
77
|
+
expect(svg).not.toContain('data-start-bind');
|
|
78
|
+
expect(svg).not.toContain('data-end-bind');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('empty groupIds array serializes as no attribute', () => {
|
|
82
|
+
const svg = strokesToSvg([
|
|
83
|
+
{
|
|
84
|
+
id: 's_1',
|
|
85
|
+
tool: 'rect',
|
|
86
|
+
color: '#1f1f1f',
|
|
87
|
+
width: 3,
|
|
88
|
+
x: 0,
|
|
89
|
+
y: 0,
|
|
90
|
+
w: 10,
|
|
91
|
+
h: 10,
|
|
92
|
+
groupIds: [],
|
|
93
|
+
},
|
|
94
|
+
]);
|
|
95
|
+
expect(svg).not.toContain('data-group-ids');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('poisoned input hardening', () => {
|
|
100
|
+
test('malformed / out-of-range bind values are rejected on parse', () => {
|
|
101
|
+
const bad = [
|
|
102
|
+
'data-start-bind="onlyhost"',
|
|
103
|
+
'data-start-bind="h 2 0.5"', // nx out of [0..1]
|
|
104
|
+
'data-start-bind="h NaN 0.5"',
|
|
105
|
+
'data-start-bind="h 0.5 -1"',
|
|
106
|
+
];
|
|
107
|
+
for (const attr of bad) {
|
|
108
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><g data-id="s_x" data-tool="arrow" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" ${attr}><line x1="0" y1="0" x2="9" y2="0"/><polyline points="1,1 9,0 1,-1" fill="#1f1f1f"/></g></svg>`;
|
|
109
|
+
const arrow = svgToStrokes(svg).find((s) => s.tool === 'arrow') as ArrowStroke;
|
|
110
|
+
expect(arrow).toBeDefined();
|
|
111
|
+
expect(arrow.startBind).toBeUndefined();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('data-author only honours the literal "ai"', () => {
|
|
116
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_x" data-tool="rect" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="0" y="0" width="10" height="10" data-author="evil"/></svg>`;
|
|
117
|
+
const rect = svgToStrokes(svg)[0] as RectStroke;
|
|
118
|
+
expect('author' in rect).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('sanitizer still strips handlers glued next to the new attrs', () => {
|
|
122
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><rect data-tool="rect" data-group-ids="g_1" onload="alert(1)" x="0" y="0" width="10" height="10"/></svg>`;
|
|
123
|
+
const clean = sanitizeAnnotationSvg(svg);
|
|
124
|
+
expect(clean).toContain('data-group-ids="g_1"');
|
|
125
|
+
expect(clean).not.toContain('onload');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('rotation (FigJam v3)', () => {
|
|
130
|
+
test('serializes data-rot + presentational transform only when non-zero; round-trips', () => {
|
|
131
|
+
const r: Stroke = {
|
|
132
|
+
id: 's_r',
|
|
133
|
+
tool: 'rect',
|
|
134
|
+
color: '#1f1f1f',
|
|
135
|
+
width: 3,
|
|
136
|
+
x: 0,
|
|
137
|
+
y: 0,
|
|
138
|
+
w: 100,
|
|
139
|
+
h: 50,
|
|
140
|
+
rotation: 30,
|
|
141
|
+
};
|
|
142
|
+
const svg = strokesToSvg([r]);
|
|
143
|
+
expect(svg).toContain('data-rot="30"');
|
|
144
|
+
expect(svg).toContain('transform="rotate(30 50 25)"');
|
|
145
|
+
const back = svgToStrokes(svg);
|
|
146
|
+
expect((back[0] as RectStroke).rotation).toBe(30);
|
|
147
|
+
expect(strokesToSvg(back)).toBe(svg);
|
|
148
|
+
expect(strokesToSvg([{ ...r, rotation: 0 }])).not.toContain('data-rot');
|
|
149
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('hit-testing happens in the rotated frame', () => {
|
|
153
|
+
// A wide flat bar rotated 90° stands upright: a probe above the original
|
|
154
|
+
// top edge now hits; the original east tip is empty space.
|
|
155
|
+
const bar: Stroke = {
|
|
156
|
+
id: 's_b',
|
|
157
|
+
tool: 'rect',
|
|
158
|
+
color: '#000',
|
|
159
|
+
width: 2,
|
|
160
|
+
x: 0,
|
|
161
|
+
y: 0,
|
|
162
|
+
w: 100,
|
|
163
|
+
h: 10,
|
|
164
|
+
fill: '#fff',
|
|
165
|
+
rotation: 90,
|
|
166
|
+
};
|
|
167
|
+
expect(strokeHitTest(bar, 50, -40, 2)).toBe(true);
|
|
168
|
+
expect(strokeHitTest(bar, 95, 5, 2)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('pen/arrow/anchored text ignore rotation (no attr emitted)', () => {
|
|
172
|
+
const pen: Stroke = {
|
|
173
|
+
id: 's_p',
|
|
174
|
+
tool: 'pen',
|
|
175
|
+
color: '#000',
|
|
176
|
+
width: 3,
|
|
177
|
+
points: [
|
|
178
|
+
[0, 0],
|
|
179
|
+
[10, 10],
|
|
180
|
+
],
|
|
181
|
+
rotation: 45,
|
|
182
|
+
};
|
|
183
|
+
expect(strokesToSvg([pen])).not.toContain('data-rot');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('polygon anchored text (Wave G)', () => {
|
|
188
|
+
const diamond: PolygonStroke = {
|
|
189
|
+
id: 'poly1',
|
|
190
|
+
tool: 'polygon',
|
|
191
|
+
shape: 'diamond',
|
|
192
|
+
color: '#1a1a1a',
|
|
193
|
+
width: 2,
|
|
194
|
+
x: 10,
|
|
195
|
+
y: 20,
|
|
196
|
+
w: 120,
|
|
197
|
+
h: 80,
|
|
198
|
+
};
|
|
199
|
+
const label: TextStroke = {
|
|
200
|
+
id: 'txt1',
|
|
201
|
+
tool: 'text',
|
|
202
|
+
color: '#1a1a1a',
|
|
203
|
+
fontSize: 16,
|
|
204
|
+
text: 'decision',
|
|
205
|
+
anchorId: 'poly1',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
test('anchored text inherits a polygon host bbox', () => {
|
|
209
|
+
const anchors = new Map<string, AnchorHost>([[diamond.id, diamond]]);
|
|
210
|
+
expect(strokeBBox(label, anchors)).toEqual({ x: 10, y: 20, w: 120, h: 80 });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('serialize → parse round-trips the anchor binding', () => {
|
|
214
|
+
const svg = strokesToSvg([diamond, label]);
|
|
215
|
+
expect(svg).toContain('data-anchor-id="poly1"');
|
|
216
|
+
const back = svgToStrokes(svg);
|
|
217
|
+
const txt = back.find((s) => s.tool === 'text') as TextStroke;
|
|
218
|
+
expect(txt.anchorId).toBe('poly1');
|
|
219
|
+
expect(strokesToSvg(back)).toBe(svg);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('shape-kind conversion (Wave H)', () => {
|
|
224
|
+
const base: RectStroke = {
|
|
225
|
+
id: 's_conv',
|
|
226
|
+
tool: 'rect',
|
|
227
|
+
color: '#3b82f6',
|
|
228
|
+
width: 3,
|
|
229
|
+
x: 10,
|
|
230
|
+
y: 20,
|
|
231
|
+
w: 100,
|
|
232
|
+
h: 60,
|
|
233
|
+
fill: '#e0ebfd',
|
|
234
|
+
rotation: 30,
|
|
235
|
+
groupIds: ['g_1'],
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
test('shapeKindOf maps every closed shape; null otherwise', () => {
|
|
239
|
+
expect(shapeKindOf(base)).toBe('square');
|
|
240
|
+
expect(shapeKindOf({ ...base, cornerRadius: 8 })).toBe('rounded');
|
|
241
|
+
expect(
|
|
242
|
+
shapeKindOf({ id: 'e', tool: 'ellipse', color: '#000', width: 2, cx: 0, cy: 0, rx: 5, ry: 5 })
|
|
243
|
+
).toBe('circle');
|
|
244
|
+
expect(
|
|
245
|
+
shapeKindOf({
|
|
246
|
+
id: 'p',
|
|
247
|
+
tool: 'polygon',
|
|
248
|
+
shape: 'diamond',
|
|
249
|
+
color: '#000',
|
|
250
|
+
width: 2,
|
|
251
|
+
x: 0,
|
|
252
|
+
y: 0,
|
|
253
|
+
w: 10,
|
|
254
|
+
h: 10,
|
|
255
|
+
})
|
|
256
|
+
).toBe('diamond');
|
|
257
|
+
expect(shapeKindOf({ id: 't', tool: 'text', color: '#000', fontSize: 14, text: 'x' })).toBe(
|
|
258
|
+
null
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('rect → circle preserves bbox, styling, rotation, groups, id', () => {
|
|
263
|
+
const patch = convertShapeKind(base, 'circle');
|
|
264
|
+
expect(patch).toMatchObject({ tool: 'ellipse', cx: 60, cy: 50, rx: 50, ry: 30 });
|
|
265
|
+
const converted = { ...base, ...patch } as Stroke;
|
|
266
|
+
// Styling + identity ride along via the merge; stale rect geometry cleared.
|
|
267
|
+
expect(converted.id).toBe('s_conv');
|
|
268
|
+
expect(converted.color).toBe('#3b82f6');
|
|
269
|
+
expect((converted as { fill?: string | null }).fill).toBe('#e0ebfd');
|
|
270
|
+
expect(converted.rotation).toBe(30);
|
|
271
|
+
expect(converted.groupIds).toEqual(['g_1']);
|
|
272
|
+
expect((converted as { x?: number }).x).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('circle → diamond → square round-trips the bbox', () => {
|
|
276
|
+
const circle = {
|
|
277
|
+
id: 'e2',
|
|
278
|
+
tool: 'ellipse',
|
|
279
|
+
color: '#000',
|
|
280
|
+
width: 2,
|
|
281
|
+
cx: 60,
|
|
282
|
+
cy: 50,
|
|
283
|
+
rx: 50,
|
|
284
|
+
ry: 30,
|
|
285
|
+
} as Stroke;
|
|
286
|
+
const toDiamond = { ...circle, ...convertShapeKind(circle, 'diamond') } as Stroke;
|
|
287
|
+
expect(toDiamond).toMatchObject({
|
|
288
|
+
tool: 'polygon',
|
|
289
|
+
shape: 'diamond',
|
|
290
|
+
x: 10,
|
|
291
|
+
y: 20,
|
|
292
|
+
w: 100,
|
|
293
|
+
h: 60,
|
|
294
|
+
});
|
|
295
|
+
const toSquare = { ...toDiamond, ...convertShapeKind(toDiamond, 'square') } as Stroke;
|
|
296
|
+
expect(toSquare).toMatchObject({ tool: 'rect', x: 10, y: 20, w: 100, h: 60, cornerRadius: 0 });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('identity + non-shape conversions return null', () => {
|
|
300
|
+
expect(convertShapeKind(base, 'square')).toBe(null);
|
|
301
|
+
expect(
|
|
302
|
+
convertShapeKind({ id: 't', tool: 'text', color: '#000', fontSize: 14, text: 'x' }, 'circle')
|
|
303
|
+
).toBe(null);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('converted stroke serializes cleanly and round-trips', () => {
|
|
307
|
+
const converted = { ...base, ...convertShapeKind(base, 'triangle') } as Stroke;
|
|
308
|
+
const svg = strokesToSvg([converted]);
|
|
309
|
+
expect(svg).toContain('data-shape="triangle"');
|
|
310
|
+
expect(strokesToSvg(svgToStrokes(svg))).toBe(svg);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('non-finite geometry hardening (Wave H — security F4)', () => {
|
|
315
|
+
test('Infinity / NaN / non-numeric coords clamp to 0 on parse (no off-board spoof)', () => {
|
|
316
|
+
// A poisoned/synced SVG carries x="1e999" (→ Infinity) and a non-numeric y.
|
|
317
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_evil" data-tool="rect" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="1e999" y="abc" width="100" height="50"/></svg>`;
|
|
318
|
+
const rect = svgToStrokes(svg)[0] as RectStroke;
|
|
319
|
+
expect(rect).toBeDefined();
|
|
320
|
+
expect(Number.isFinite(rect.x)).toBe(true);
|
|
321
|
+
expect(Number.isFinite(rect.y)).toBe(true);
|
|
322
|
+
expect(rect.x).toBe(0);
|
|
323
|
+
expect(rect.y).toBe(0);
|
|
324
|
+
// The bbox the AI reader + the renderer both consume is finite — they agree.
|
|
325
|
+
const bb = strokeBBox(rect);
|
|
326
|
+
expect(bb && Number.isFinite(bb.x) && Number.isFinite(bb.w)).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('polygon with an Infinity vertex does not blow the bbox to Infinity', () => {
|
|
330
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><polygon data-id="s_p" data-tool="polygon" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-shape="diamond" points="50,0 1e999,25 50,50 0,25"/></svg>`;
|
|
331
|
+
const poly = svgToStrokes(svg)[0] as PolygonStroke;
|
|
332
|
+
if (poly) {
|
|
333
|
+
expect(Number.isFinite(poly.w)).toBe(true);
|
|
334
|
+
expect(Number.isFinite(poly.x)).toBe(true);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('finite geometry is untouched (round-trip stays byte-identical)', () => {
|
|
339
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_ok" data-tool="rect" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="12.5" y="-3" width="100" height="50"/></svg>`;
|
|
340
|
+
expect(strokesToSvg(svgToStrokes(svg))).toBe(svg);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_aaa1" data-tool="rect" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#e0ebfd" x="100" y="100" width="160" height="100" data-group-ids="g_team1"/><g data-id="s_aaa2" data-tool="sticky" data-r="8" data-fs="14" fill="#fce8a6" data-group-ids="g_inner g_team1"><rect x="320" y="100" width="200" height="200" rx="8" ry="8"/><text data-sticky-body="1" x="332" y="112" font-size="14" fill="#1a1a1a" dominant-baseline="hanging">flow start</text></g><g data-id="s_aaa3" data-tool="arrow" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-start-bind="s_aaa1 1 0.5" data-end-bind="s_aaa2 0 0.5 p"><line x1="260" y1="150" x2="320" y2="200"/><polyline points="307.1972004393032,196.78622909829892 320,200 314.5302123070536,187.9866148569984" fill="#1f1f1f"/></g><text data-id="s_aaa4" data-tool="text" x="100" y="260" data-font-size="14" fill="#1f1f1f" text-anchor="start" dominant-baseline="hanging" data-author="ai">AI note</text><ellipse data-id="s_aaa5" data-tool="ellipse" stroke="#30a46c" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" cx="700" cy="150" rx="60" ry="40" data-rot="15" transform="rotate(15 700 150)"/></svg>
|
|
@@ -12,7 +12,8 @@ import * as Y from 'yjs';
|
|
|
12
12
|
|
|
13
13
|
import { createPersistence, Y_TYPES } from '../collab/persistence.ts';
|
|
14
14
|
import type { Context } from '../context.ts';
|
|
15
|
-
import { applyCommentsToDoc, applyHtmlToDoc } from '../sync/codec.ts';
|
|
15
|
+
import { applyCommentsToDoc, applyCssToDoc, applyHtmlToDoc } from '../sync/codec.ts';
|
|
16
|
+
import { hashBytes } from '../sync/echo-guard.ts';
|
|
16
17
|
import { docIsEmpty, migrateSeed } from '../sync/migrate-seed.ts';
|
|
17
18
|
|
|
18
19
|
let dir: string;
|
|
@@ -43,28 +44,28 @@ describe('docIsEmpty', () => {
|
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
describe('migrateSeed — adopt (hub was empty)', () => {
|
|
46
|
-
test('seeds an empty doc from local files and reports local-adopt', () => {
|
|
47
|
+
test('seeds an empty doc from local files and reports local-adopt', async () => {
|
|
47
48
|
const p = paths();
|
|
48
49
|
writeFileSync(p.html, '<main>local body</main>');
|
|
49
50
|
writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'local' }]));
|
|
50
51
|
|
|
51
52
|
const doc = new Y.Doc();
|
|
52
|
-
const result = migrateSeed({ slug: 's', doc, paths: p });
|
|
53
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p });
|
|
53
54
|
|
|
54
55
|
expect(result).toBe('local-adopt');
|
|
55
56
|
expect(doc.getText('html').toString()).toBe('<main>local body</main>');
|
|
56
57
|
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'local' }]);
|
|
57
58
|
});
|
|
58
59
|
|
|
59
|
-
test('empty doc + no local files → "empty" (nothing to seed)', () => {
|
|
60
|
+
test('empty doc + no local files → "empty" (nothing to seed)', async () => {
|
|
60
61
|
const doc = new Y.Doc();
|
|
61
|
-
expect(migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
|
|
62
|
+
expect(await migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
|
|
62
63
|
expect(docIsEmpty(doc)).toBe(true);
|
|
63
64
|
});
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
describe('migrateSeed — idempotent (no duplication on re-run)', () => {
|
|
67
|
-
test('adopting twice leaves comments un-duplicated', () => {
|
|
68
|
+
test('adopting twice leaves comments un-duplicated', async () => {
|
|
68
69
|
const p = paths();
|
|
69
70
|
writeFileSync(
|
|
70
71
|
p.comments,
|
|
@@ -75,12 +76,12 @@ describe('migrateSeed — idempotent (no duplication on re-run)', () => {
|
|
|
75
76
|
);
|
|
76
77
|
|
|
77
78
|
const doc = new Y.Doc();
|
|
78
|
-
expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
|
|
79
|
+
expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
|
|
79
80
|
expect(doc.getArray(Y_TYPES.comments).length).toBe(2);
|
|
80
81
|
|
|
81
82
|
// Second run (e.g. server restart): the doc is now non-empty → hub-wins
|
|
82
83
|
// branch → no re-adopt → still exactly 2 comments, byte-identical.
|
|
83
|
-
expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
|
|
84
|
+
expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
|
|
84
85
|
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
|
|
85
86
|
{ id: 'c1', text: 'x' },
|
|
86
87
|
{ id: 'c2', text: 'y' },
|
|
@@ -123,7 +124,7 @@ describe('room file-seed disable (the other half of the Risk-1 fix)', () => {
|
|
|
123
124
|
});
|
|
124
125
|
|
|
125
126
|
describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
126
|
-
test('a doc already holding hub comments is NOT merged with the local file → no dup', () => {
|
|
127
|
+
test('a doc already holding hub comments is NOT merged with the local file → no dup', async () => {
|
|
127
128
|
const p = paths();
|
|
128
129
|
// Local file (e.g. copied via git) holds the SAME logical comment id…
|
|
129
130
|
writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'from disk' }]));
|
|
@@ -132,14 +133,15 @@ describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
|
132
133
|
const doc = new Y.Doc();
|
|
133
134
|
applyCommentsToDoc(doc, [{ id: 'c1', text: 'from hub' }], 'hub');
|
|
134
135
|
|
|
135
|
-
const result = migrateSeed({ slug: 's', doc, paths: p });
|
|
136
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p });
|
|
136
137
|
|
|
137
|
-
// Hub wins; the local file is NOT pushed as a second item — exactly ONE c1
|
|
138
|
+
// Hub wins; the local file is NOT pushed as a second item — exactly ONE c1
|
|
139
|
+
// (DDR-102 id-union keeps the doc's version for a shared id).
|
|
138
140
|
expect(result).toBe('hub-wins');
|
|
139
141
|
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'from hub' }]);
|
|
140
142
|
});
|
|
141
143
|
|
|
142
|
-
test('snapshots divergent local files to historyDir before the hub-wins overwrite', () => {
|
|
144
|
+
test('snapshots divergent local files to historyDir before the hub-wins overwrite', async () => {
|
|
143
145
|
const p = paths();
|
|
144
146
|
writeFileSync(p.html, '<main>LOCAL divergent</main>');
|
|
145
147
|
writeFileSync(p.comments, JSON.stringify([{ id: 'local' }]));
|
|
@@ -148,12 +150,220 @@ describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
|
148
150
|
applyHtmlToDoc(doc, '<main>HUB</main>', 'hub'); // doc holds hub state
|
|
149
151
|
|
|
150
152
|
const historyDir = join(dir, '_history', 's');
|
|
151
|
-
|
|
153
|
+
// DDR-102: this divergence (no journal, no doc stamp) resolves
|
|
154
|
+
// conflict-hub-wins — the legacy whole-set safety copy still happens.
|
|
155
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p, historyDir });
|
|
152
156
|
|
|
153
|
-
expect(result).toBe('hub-wins');
|
|
157
|
+
expect(result).toBe('conflict-hub-wins');
|
|
154
158
|
// The pre-migration snapshot captured the local files for rollback.
|
|
155
159
|
const snapped = readdirSync(join(historyDir, 'pre-shared-doc-migration'));
|
|
156
160
|
expect(snapped).toContain('screen.html');
|
|
157
161
|
expect(snapped).toContain('screen.comments.json');
|
|
158
162
|
});
|
|
159
163
|
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// DDR-102 — the shared-doc path consumes the same cold-start decision table:
|
|
167
|
+
// journal-gated fast-forward, dual snapshot + newest-wins on divergence,
|
|
168
|
+
// comments id-union.
|
|
169
|
+
describe('migrateSeed — DDR-102 divergence matrix', () => {
|
|
170
|
+
function makeJournal(initial: Record<string, { bodyHash: string }> = {}) {
|
|
171
|
+
const entries = new Map(Object.entries(initial));
|
|
172
|
+
return {
|
|
173
|
+
get: (slug: string) => entries.get(slug) ?? null,
|
|
174
|
+
record: (slug: string, hashes: { bodyHash?: string; cssHash?: string }) => {
|
|
175
|
+
const prev = entries.get(slug);
|
|
176
|
+
entries.set(slug, { bodyHash: hashes.bodyHash ?? prev?.bodyHash ?? '' });
|
|
177
|
+
},
|
|
178
|
+
invalidateIfHubChanged: () => {},
|
|
179
|
+
flush: () => {},
|
|
180
|
+
stop: () => {},
|
|
181
|
+
size: () => entries.size,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test('journal match → fast-forward (hub keeps), no snapshot, no conflict', async () => {
|
|
186
|
+
const p = paths();
|
|
187
|
+
writeFileSync(p.html, '<main>synced-v1</main>');
|
|
188
|
+
const doc = new Y.Doc();
|
|
189
|
+
applyHtmlToDoc(doc, '<main>hub-v2</main>', 'hub');
|
|
190
|
+
|
|
191
|
+
const journal = makeJournal({ s: { bodyHash: hashBytes('<main>synced-v1</main>') } });
|
|
192
|
+
const snapshots: string[] = [];
|
|
193
|
+
const conflicts: unknown[] = [];
|
|
194
|
+
const result = await migrateSeed({
|
|
195
|
+
slug: 's',
|
|
196
|
+
doc,
|
|
197
|
+
paths: p,
|
|
198
|
+
journal,
|
|
199
|
+
snapshot: async (_c, reason) => {
|
|
200
|
+
snapshots.push(reason);
|
|
201
|
+
return 'ts';
|
|
202
|
+
},
|
|
203
|
+
onConflict: (info) => {
|
|
204
|
+
conflicts.push(info);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result).toBe('hub-wins');
|
|
209
|
+
expect(doc.getText('html').toString()).toBe('<main>hub-v2</main>'); // doc untouched
|
|
210
|
+
expect(snapshots).toHaveLength(0);
|
|
211
|
+
expect(conflicts).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('diverged + local newer → conflict-local-wins: doc REBUILT from local in one MIGRATION tx', async () => {
|
|
215
|
+
const p = paths();
|
|
216
|
+
writeFileSync(p.html, '<main>local-newer</main>');
|
|
217
|
+
writeFileSync(p.css, '.local{}');
|
|
218
|
+
const doc = new Y.Doc();
|
|
219
|
+
doc.transact(() => {
|
|
220
|
+
applyHtmlToDoc(doc, '<main>hub-older</main>', 'hub');
|
|
221
|
+
applyCssToDoc(doc, '.hub{}', 'hub');
|
|
222
|
+
doc.getMap('syncMeta').set('bodyEditAt', Date.now() - 60_000);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const snapshots: Array<{ content: string; reason: string }> = [];
|
|
226
|
+
const conflicts: Array<{ winner?: string; snapshots?: { local?: string; hub?: string } }> = [];
|
|
227
|
+
const result = await migrateSeed({
|
|
228
|
+
slug: 's',
|
|
229
|
+
doc,
|
|
230
|
+
paths: p,
|
|
231
|
+
journal: makeJournal(),
|
|
232
|
+
snapshot: async (content, reason) => {
|
|
233
|
+
snapshots.push({ content, reason });
|
|
234
|
+
return `ts-${snapshots.length}`;
|
|
235
|
+
},
|
|
236
|
+
onConflict: (info) => {
|
|
237
|
+
conflicts.push(info);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(result).toBe('conflict-local-wins');
|
|
242
|
+
expect(doc.getText('html').toString()).toBe('<main>local-newer</main>');
|
|
243
|
+
// css follows the body winner.
|
|
244
|
+
expect(doc.getText('css').toString()).toBe('.local{}');
|
|
245
|
+
// Dual snapshot captured BOTH pre-resolution versions.
|
|
246
|
+
expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
|
|
247
|
+
expect(snapshots[0].content).toBe('<main>local-newer</main>');
|
|
248
|
+
expect(snapshots[1].content).toBe('<main>hub-older</main>');
|
|
249
|
+
expect(conflicts[0].winner).toBe('local');
|
|
250
|
+
expect(conflicts[0].snapshots).toEqual({ local: 'ts-1', hub: 'ts-2' });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('diverged + hub newer (stamp) → conflict-hub-wins: doc untouched', async () => {
|
|
254
|
+
const p = paths();
|
|
255
|
+
writeFileSync(p.html, '<main>local-older</main>');
|
|
256
|
+
const doc = new Y.Doc();
|
|
257
|
+
doc.transact(() => {
|
|
258
|
+
applyHtmlToDoc(doc, '<main>hub-newer</main>', 'hub');
|
|
259
|
+
doc.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const conflicts: Array<{ winner?: string }> = [];
|
|
263
|
+
const result = await migrateSeed({
|
|
264
|
+
slug: 's',
|
|
265
|
+
doc,
|
|
266
|
+
paths: p,
|
|
267
|
+
journal: makeJournal(),
|
|
268
|
+
onConflict: (info) => {
|
|
269
|
+
conflicts.push(info);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(result).toBe('conflict-hub-wins');
|
|
274
|
+
expect(doc.getText('html').toString()).toBe('<main>hub-newer</main>');
|
|
275
|
+
expect(conflicts[0].winner).toBe('hub');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('doc holds comments but NO body + local body → body-seed-up (gap closed)', async () => {
|
|
279
|
+
const p = paths();
|
|
280
|
+
writeFileSync(p.html, '<main>only-local-body</main>');
|
|
281
|
+
const doc = new Y.Doc();
|
|
282
|
+
doc.getArray(Y_TYPES.comments).push([{ id: 'h1' }]); // doc non-empty, body empty
|
|
283
|
+
|
|
284
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p, journal: makeJournal() });
|
|
285
|
+
|
|
286
|
+
expect(result).toBe('body-seed-up');
|
|
287
|
+
expect(doc.getText('html').toString()).toBe('<main>only-local-body</main>');
|
|
288
|
+
// Hub comments survive untouched.
|
|
289
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'h1' }]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('comments id-union: local-only comments survive a hub-wins cutover', async () => {
|
|
293
|
+
const p = paths();
|
|
294
|
+
writeFileSync(p.html, '<main>same</main>');
|
|
295
|
+
writeFileSync(
|
|
296
|
+
p.comments,
|
|
297
|
+
JSON.stringify([
|
|
298
|
+
{ id: 'c1', text: 'shared' },
|
|
299
|
+
{ id: 'c3', text: 'local-only' },
|
|
300
|
+
])
|
|
301
|
+
);
|
|
302
|
+
const doc = new Y.Doc();
|
|
303
|
+
doc.transact(() => {
|
|
304
|
+
applyHtmlToDoc(doc, '<main>same</main>', 'hub');
|
|
305
|
+
applyCommentsToDoc(
|
|
306
|
+
doc,
|
|
307
|
+
[
|
|
308
|
+
{ id: 'c1', text: 'shared (hub copy wins)' },
|
|
309
|
+
{ id: 'c2', text: 'hub-only' },
|
|
310
|
+
],
|
|
311
|
+
'hub'
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p });
|
|
316
|
+
|
|
317
|
+
expect(result).toBe('hub-wins');
|
|
318
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
|
|
319
|
+
{ id: 'c1', text: 'shared (hub copy wins)' },
|
|
320
|
+
{ id: 'c2', text: 'hub-only' },
|
|
321
|
+
{ id: 'c3', text: 'local-only' },
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// DDR-102 fail-closed (security review F1) — the shared-doc path must also
|
|
327
|
+
// refuse a hub-wins cutover when the local snapshot didn't land.
|
|
328
|
+
describe('migrateSeed — fail-closed on snapshot failure (DDR-102 F1)', () => {
|
|
329
|
+
function makeJournal() {
|
|
330
|
+
const entries = new Map<string, { bodyHash: string }>();
|
|
331
|
+
return {
|
|
332
|
+
get: (slug: string) => entries.get(slug) ?? null,
|
|
333
|
+
record: (slug: string, h: { bodyHash?: string; cssHash?: string }) =>
|
|
334
|
+
entries.set(slug, { bodyHash: h.bodyHash ?? entries.get(slug)?.bodyHash ?? '' }),
|
|
335
|
+
invalidateIfHubChanged: () => {},
|
|
336
|
+
flush: () => {},
|
|
337
|
+
stop: () => {},
|
|
338
|
+
size: () => entries.size,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
test('hub newer BUT local snapshot null → rebuild doc from local (no overwrite), conflict-local-wins', async () => {
|
|
343
|
+
const p = paths();
|
|
344
|
+
writeFileSync(p.html, '<main>local-work</main>');
|
|
345
|
+
const doc = new Y.Doc();
|
|
346
|
+
doc.transact(() => {
|
|
347
|
+
applyHtmlToDoc(doc, '<main>hub-stale</main>', 'hub');
|
|
348
|
+
doc.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000); // hub would win
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
|
|
352
|
+
const result = await migrateSeed({
|
|
353
|
+
slug: 's',
|
|
354
|
+
doc,
|
|
355
|
+
paths: p,
|
|
356
|
+
journal: makeJournal(),
|
|
357
|
+
snapshot: async () => null, // _history write fails
|
|
358
|
+
onConflict: (info) => {
|
|
359
|
+
conflicts.push(info);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Doc rebuilt from local → projection would write local back (no loss).
|
|
364
|
+
expect(result).toBe('conflict-local-wins');
|
|
365
|
+
expect(doc.getText('html').toString()).toBe('<main>local-work</main>');
|
|
366
|
+
expect(conflicts[0].winner).toBe('local');
|
|
367
|
+
expect(conflicts[0].snapshotFailed).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
});
|