@1agh/maude 0.60.4 → 0.60.6
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/apps/studio/annotations-layer.tsx +21 -12
- package/apps/studio/api.ts +54 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-comment-mount.tsx +7 -1
- package/apps/studio/canvas-icons.tsx +30 -2
- package/apps/studio/canvas-lib.tsx +41 -6
- package/apps/studio/canvas-shell.tsx +39 -43
- package/apps/studio/client/app.jsx +20 -13
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/hmr-broadcast.ts +40 -2
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-wheel-delta.test.ts +42 -0
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/comment-mount.test.ts +10 -1
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/hmr-broadcast.test.ts +6 -4
- package/apps/studio/test/hmr-classify.test.ts +18 -1
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/mode-toggle.test.tsx +226 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/use-tool-mode.test.tsx +33 -3
- package/apps/studio/tool-palette.tsx +86 -24
- package/apps/studio/use-tool-mode.tsx +113 -14
- package/apps/studio/whats-new.json +45 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +31 -0
|
@@ -59,7 +59,24 @@ describe('classifyChange', () => {
|
|
|
59
59
|
|
|
60
60
|
test('unrelated extensions → null', () => {
|
|
61
61
|
expect(classifyChange('ui/notes.md', noSibling)).toBeNull();
|
|
62
|
-
expect(classifyChange('assets/
|
|
62
|
+
expect(classifyChange('assets/photo.png.part', noSibling)).toBeNull();
|
|
63
|
+
expect(classifyChange('system/x/assets/fonts/Gators-Bold.woff2', noSibling)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('media files → asset heal signal (2026-08-15 annotations-assets RCA)', () => {
|
|
67
|
+
// A synced-down media file landing on disk heals still-broken <img>/<image>
|
|
68
|
+
// elements in open canvases — no reload. Top-level assets/ and DS trees both.
|
|
69
|
+
for (const f of [
|
|
70
|
+
'assets/65c3a940.png',
|
|
71
|
+
'assets/807d7ba7.jpg',
|
|
72
|
+
'assets/logo.svg',
|
|
73
|
+
'assets/clip.mp4',
|
|
74
|
+
'system/alligators/assets/photos-cut/frame-helmet.jpg',
|
|
75
|
+
]) {
|
|
76
|
+
const msg = classifyChange(f, noSibling);
|
|
77
|
+
expect(msg?.mode).toBe('asset');
|
|
78
|
+
expect(msg?.file).toBe(f);
|
|
79
|
+
}
|
|
63
80
|
});
|
|
64
81
|
|
|
65
82
|
test('backslash paths are normalised before classification', () => {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DRAG_THRESHOLD_PX,
|
|
10
10
|
isArtboardDragChrome,
|
|
11
11
|
isEditableTarget,
|
|
12
|
+
isOverlayTarget,
|
|
12
13
|
type Tool,
|
|
13
14
|
yieldsClickToArtboardChrome,
|
|
14
15
|
yieldsToArtboardDrag,
|
|
@@ -496,6 +497,74 @@ describe('input-router / isArtboardDragChrome', () => {
|
|
|
496
497
|
});
|
|
497
498
|
});
|
|
498
499
|
|
|
500
|
+
// issue-90 — once comment mode is armed, a bare click anywhere on the shell's
|
|
501
|
+
// floating chrome (tool palette, zoom pill, minimap, right-click context
|
|
502
|
+
// menu) was claimed as `drop-comment` and dropped a stray pin at the
|
|
503
|
+
// chrome's own coordinates instead of reaching the chrome's own React
|
|
504
|
+
// onClick — the palette became unusable the moment the user picked the
|
|
505
|
+
// Comment tool. `classifyContextKind` (canvas-shell.tsx) already treats
|
|
506
|
+
// these same selectors as non-canvas `'overlay'` chrome; `isOverlayTarget`
|
|
507
|
+
// was the one claim gate that didn't.
|
|
508
|
+
describe('input-router / isOverlayTarget — shell chrome exemptions (issue-90)', () => {
|
|
509
|
+
// isOverlayTarget makes ONE `closest(a, b, c, ...)` call against the full
|
|
510
|
+
// comma-joined selector list, unlike isArtboardDragChrome's fakeTarget
|
|
511
|
+
// above (which mimics one call per bare selector) — so the stub instead
|
|
512
|
+
// checks whether any of the target's own classes appears among the
|
|
513
|
+
// selector's comma-separated parts, same as a real `Element.closest` would
|
|
514
|
+
// resolve for a node carrying that class.
|
|
515
|
+
const fakeTarget = (memberOf: string[]): EventTarget =>
|
|
516
|
+
({
|
|
517
|
+
closest: (sel: string) => (memberOf.some((cls) => sel.includes(cls)) ? {} : null),
|
|
518
|
+
}) as unknown as EventTarget;
|
|
519
|
+
|
|
520
|
+
test('null target → false', () => {
|
|
521
|
+
expect(isOverlayTarget(null)).toBe(false);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('a click on the tool palette is exempt', () => {
|
|
525
|
+
expect(isOverlayTarget(fakeTarget(['.dc-tool-palette']))).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('a click on the zoom toolbar is exempt', () => {
|
|
529
|
+
expect(isOverlayTarget(fakeTarget(['.dc-zoom-tb']))).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('a click on the minimap is exempt', () => {
|
|
533
|
+
expect(isOverlayTarget(fakeTarget(['.dc-mm']))).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('a click on the right-click context menu is exempt', () => {
|
|
537
|
+
expect(isOverlayTarget(fakeTarget(['.dc-context-menu']))).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('a click on ordinary canvas content is NOT exempt', () => {
|
|
541
|
+
expect(isOverlayTarget(fakeTarget([]))).toBe(false);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('the pre-existing comment-overlay exemptions still hold', () => {
|
|
545
|
+
expect(isOverlayTarget(fakeTarget(['.cm-composer']))).toBe(true);
|
|
546
|
+
expect(isOverlayTarget(fakeTarget(['.cm-thread']))).toBe(true);
|
|
547
|
+
expect(isOverlayTarget(fakeTarget(['.cm-mention-popup']))).toBe(true);
|
|
548
|
+
expect(isOverlayTarget(fakeTarget(['.cm-pin']))).toBe(true);
|
|
549
|
+
expect(isOverlayTarget(fakeTarget(['[data-mediaref-player]']))).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Security review finding (issue-90) — `.dc-world` is where the ACTIVE
|
|
553
|
+
// CANVAS's own untrusted, AI/user-authored JSX renders. None of the real
|
|
554
|
+
// chrome ever lives there, so an element inside `.dc-world` that ALSO
|
|
555
|
+
// carries one of these class names is necessarily a same-name imposter —
|
|
556
|
+
// untrusted canvas content spoofing the shell's own chrome to make the
|
|
557
|
+
// router yield (skip preventDefault) on it instead of claiming the
|
|
558
|
+
// gesture. Must be rejected regardless of which chrome class it copies.
|
|
559
|
+
test('an element inside .dc-world carrying a spoofed chrome class is NOT exempt', () => {
|
|
560
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-tool-palette']))).toBe(false);
|
|
561
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-zoom-tb']))).toBe(false);
|
|
562
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-mm']))).toBe(false);
|
|
563
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-context-menu']))).toBe(false);
|
|
564
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.cm-composer']))).toBe(false);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
499
568
|
// issue-71 — the actual regression: before this fix, useInputRouter's
|
|
500
569
|
// pointerdown/mousedown handlers called `stopImmediatePropagation()` on EVERY
|
|
501
570
|
// claimed action, including a bare click on an artboard's own drag chrome —
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// mode-toggle — DDR-223 (issue #93). The preview/edit mode layer over the
|
|
2
|
+
// DDR-187 browse/move tool split: boot posture, the mode⇄tool invariant
|
|
3
|
+
// (arming a resting tool moves the mode; annotation tools are mode-neutral),
|
|
4
|
+
// resetTool, read-only preview boot, and the palette's segmented toggle.
|
|
5
|
+
//
|
|
6
|
+
// Needs a live DOM (context callbacks fired via act) — register happy-dom +
|
|
7
|
+
// drive a createRoot, same convention as browse-posture.test.tsx.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
GlobalRegistrator.register();
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
});
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
GlobalRegistrator.unregister();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { act, type ReactNode } from 'react';
|
|
21
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
22
|
+
import type { Tool } from '../input-router.tsx';
|
|
23
|
+
import { _resetReadOnlyCache } from '../read-only-mode.ts';
|
|
24
|
+
import { ToolPalette } from '../tool-palette.tsx';
|
|
25
|
+
import { type CanvasMode, ToolProvider, useToolMode } from '../use-tool-mode.tsx';
|
|
26
|
+
|
|
27
|
+
// Capture the live context value so tests can drive setMode/setTool/resetTool
|
|
28
|
+
// directly and assert the resulting (tool, mode) pair.
|
|
29
|
+
let ctx: ReturnType<typeof useToolMode> | null = null;
|
|
30
|
+
function Capture() {
|
|
31
|
+
ctx = useToolMode();
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let root: Root | null = null;
|
|
36
|
+
let host: HTMLElement | null = null;
|
|
37
|
+
|
|
38
|
+
function mount(ui: ReactNode): void {
|
|
39
|
+
host = document.createElement('div');
|
|
40
|
+
document.body.appendChild(host);
|
|
41
|
+
root = createRoot(host);
|
|
42
|
+
act(() => {
|
|
43
|
+
root?.render(ui);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
act(() => {
|
|
49
|
+
root?.unmount();
|
|
50
|
+
});
|
|
51
|
+
host?.remove();
|
|
52
|
+
root = null;
|
|
53
|
+
host = null;
|
|
54
|
+
ctx = null;
|
|
55
|
+
_resetReadOnlyCache();
|
|
56
|
+
setPageUrl('http://localhost/');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// happy-dom: `history.replaceState` does not update `location.search`; the
|
|
60
|
+
// DetachedWindowAPI `setURL` is the supported seam.
|
|
61
|
+
function setPageUrl(url: string): void {
|
|
62
|
+
(window as unknown as { happyDOM?: { setURL?: (u: string) => void } }).happyDOM?.setURL?.(url);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pair = (): { tool: Tool; mode: CanvasMode } => ({
|
|
66
|
+
tool: ctx?.tool as Tool,
|
|
67
|
+
mode: ctx?.mode as CanvasMode,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('mode-toggle / boot posture (DDR-223)', () => {
|
|
71
|
+
test('default boot = edit with move armed', () => {
|
|
72
|
+
mount(
|
|
73
|
+
<ToolProvider>
|
|
74
|
+
<Capture />
|
|
75
|
+
</ToolProvider>
|
|
76
|
+
);
|
|
77
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('initial="browse" boots preview (comment-mount specimen posture)', () => {
|
|
81
|
+
mount(
|
|
82
|
+
<ToolProvider initial="browse">
|
|
83
|
+
<Capture />
|
|
84
|
+
</ToolProvider>
|
|
85
|
+
);
|
|
86
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('read-only (?ro=1) boots preview/browse — the DDR-187 viewer posture', () => {
|
|
90
|
+
setPageUrl('http://localhost/?ro=1');
|
|
91
|
+
_resetReadOnlyCache();
|
|
92
|
+
mount(
|
|
93
|
+
<ToolProvider>
|
|
94
|
+
<Capture />
|
|
95
|
+
</ToolProvider>
|
|
96
|
+
);
|
|
97
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('mode-toggle / mode⇄tool invariant', () => {
|
|
102
|
+
test('setMode arms the resting tool both ways', () => {
|
|
103
|
+
mount(
|
|
104
|
+
<ToolProvider>
|
|
105
|
+
<Capture />
|
|
106
|
+
</ToolProvider>
|
|
107
|
+
);
|
|
108
|
+
act(() => ctx?.setMode('preview'));
|
|
109
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
110
|
+
act(() => ctx?.setMode('edit'));
|
|
111
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('arming a resting tool moves the mode (V / tool-set / escape hatch)', () => {
|
|
115
|
+
mount(
|
|
116
|
+
<ToolProvider>
|
|
117
|
+
<Capture />
|
|
118
|
+
</ToolProvider>
|
|
119
|
+
);
|
|
120
|
+
act(() => ctx?.setTool('browse'));
|
|
121
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
122
|
+
// The Cmd+click-in-browse escape hatch is a plain setTool('move') at its
|
|
123
|
+
// call site — the invariant is what turns it into a coherent mode flip.
|
|
124
|
+
act(() => ctx?.setTool('move'));
|
|
125
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('annotation tools are mode-neutral — pen in preview stays preview', () => {
|
|
129
|
+
mount(
|
|
130
|
+
<ToolProvider initial="browse">
|
|
131
|
+
<Capture />
|
|
132
|
+
</ToolProvider>
|
|
133
|
+
);
|
|
134
|
+
act(() => ctx?.setTool('pen'));
|
|
135
|
+
expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
|
|
136
|
+
act(() => ctx?.setTool('comment'));
|
|
137
|
+
expect(pair()).toEqual({ tool: 'comment', mode: 'preview' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('resetTool arms the CURRENT mode’s resting tool', () => {
|
|
141
|
+
mount(
|
|
142
|
+
<ToolProvider initial="browse">
|
|
143
|
+
<Capture />
|
|
144
|
+
</ToolProvider>
|
|
145
|
+
);
|
|
146
|
+
// Preview: drawing an annotation then resetting returns to browse (the
|
|
147
|
+
// alive posture) — never to move.
|
|
148
|
+
act(() => ctx?.setTool('pen'));
|
|
149
|
+
act(() => ctx?.resetTool());
|
|
150
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
151
|
+
// Edit: byte-identical to the pre-DDR-223 hardcoded setTool('move').
|
|
152
|
+
act(() => ctx?.setMode('edit'));
|
|
153
|
+
act(() => ctx?.setTool('pen'));
|
|
154
|
+
act(() => ctx?.resetTool());
|
|
155
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('mode-toggle / palette segmented toggle', () => {
|
|
160
|
+
const mountPalette = () =>
|
|
161
|
+
mount(
|
|
162
|
+
<ToolProvider>
|
|
163
|
+
<Capture />
|
|
164
|
+
<ToolPalette />
|
|
165
|
+
</ToolProvider>
|
|
166
|
+
);
|
|
167
|
+
const seg = (which: 'preview' | 'edit' | 'present'): HTMLButtonElement | null =>
|
|
168
|
+
document.querySelector(`[data-testid="palette-mode-${which}"]`);
|
|
169
|
+
|
|
170
|
+
test('renders three icon segments; Edit is pressed on boot; no browse/move tool buttons', () => {
|
|
171
|
+
mountPalette();
|
|
172
|
+
expect(seg('preview')).not.toBeNull();
|
|
173
|
+
expect(seg('edit')?.getAttribute('aria-pressed')).toBe('true');
|
|
174
|
+
expect(seg('preview')?.getAttribute('aria-pressed')).toBe('false');
|
|
175
|
+
// Owner steer 2026-08-15 — icon-only segments (lucide eye / pencil-ruler /
|
|
176
|
+
// presentation); the words live in aria-label/title.
|
|
177
|
+
expect(seg('preview')?.querySelector('svg')).not.toBeNull();
|
|
178
|
+
expect(seg('edit')?.querySelector('svg')).not.toBeNull();
|
|
179
|
+
expect(seg('present')?.querySelector('svg')).not.toBeNull();
|
|
180
|
+
// The segments ARE the resting-tool affordances — no separate Select /
|
|
181
|
+
// Browse buttons in the nav group.
|
|
182
|
+
expect(document.querySelector('[aria-label^="Select (V)"]')).toBeNull();
|
|
183
|
+
expect(document.querySelector('[aria-label^="Browse ("]')).toBeNull();
|
|
184
|
+
// Present moved INTO the toggle — exactly one presentation affordance, and
|
|
185
|
+
// it is the segment (the palette's right-end button is gone).
|
|
186
|
+
const present = document.querySelectorAll('[aria-label^="Presentation mode"]');
|
|
187
|
+
expect(present.length).toBe(1);
|
|
188
|
+
expect(present[0]).toBe(seg('present') as Element);
|
|
189
|
+
expect(seg('present')?.getAttribute('aria-pressed')).toBe('false');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('clicking Preview flips the posture; insert (+) is edit-only', () => {
|
|
193
|
+
mountPalette();
|
|
194
|
+
expect(
|
|
195
|
+
document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
|
|
196
|
+
).not.toBeNull();
|
|
197
|
+
act(() => {
|
|
198
|
+
seg('preview')?.click();
|
|
199
|
+
});
|
|
200
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
201
|
+
expect(seg('preview')?.getAttribute('aria-pressed')).toBe('true');
|
|
202
|
+
expect(
|
|
203
|
+
document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
|
|
204
|
+
).toBeNull();
|
|
205
|
+
// Draw tools stay available in preview (issue #93: annotations in preview).
|
|
206
|
+
expect(document.querySelector('[aria-label^="Pen (B)"]')).not.toBeNull();
|
|
207
|
+
// And the Edit segment arms move again.
|
|
208
|
+
act(() => {
|
|
209
|
+
seg('edit')?.click();
|
|
210
|
+
});
|
|
211
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('clicking the ACTIVE segment re-arms the resting tool', () => {
|
|
215
|
+
mountPalette();
|
|
216
|
+
act(() => {
|
|
217
|
+
seg('preview')?.click();
|
|
218
|
+
});
|
|
219
|
+
act(() => ctx?.setTool('pen'));
|
|
220
|
+
expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
|
|
221
|
+
act(() => {
|
|
222
|
+
seg('preview')?.click();
|
|
223
|
+
});
|
|
224
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Per-lane annotations cold start — the 2026-08-14 annotations eraser.
|
|
2
|
+
//
|
|
3
|
+
// The eraser: annotations used to follow the BODY winner at cold start, and
|
|
4
|
+
// the emptiness guard was `!== ''` — so a hub doc holding the bare 72-byte
|
|
5
|
+
// wrapper (`<svg …></svg>`, a non-empty STRING carrying zero strokes)
|
|
6
|
+
// overwrote a peer's real strokes whenever the hub won the body, taking the
|
|
7
|
+
// `assets/<sha8>` references the asset pull scans with them. Confirmed on
|
|
8
|
+
// alligators: a sidecar committed with two `<image>` strokes at 12:45 was the
|
|
9
|
+
// empty wrapper on every peer two minutes after the next fleet roll.
|
|
10
|
+
//
|
|
11
|
+
// The fix has three parts, each pinned here:
|
|
12
|
+
// 1. `isEmptyAnnotationsSvg` — the wrapper counts as empty.
|
|
13
|
+
// 2. `decideAnnotationsColdStart` — per-lane table; unstamped emptiness
|
|
14
|
+
// never beats content, stamped delete-all is honored by time.
|
|
15
|
+
// 3. `stampAnnotationsEdit` riding every local→doc annotations apply
|
|
16
|
+
// (applyFromFs, cold-start seed, adopt) in the same transaction.
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import * as Y from 'yjs';
|
|
23
|
+
|
|
24
|
+
import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
|
|
25
|
+
import {
|
|
26
|
+
annotationsEditAtFromDoc,
|
|
27
|
+
annotationsFromDoc,
|
|
28
|
+
applyHtmlToDoc,
|
|
29
|
+
isEmptyAnnotationsSvg,
|
|
30
|
+
stampAnnotationsEdit,
|
|
31
|
+
} from '../sync/codec.ts';
|
|
32
|
+
import { type AnnotationsColdStartInput, decideAnnotationsColdStart } from '../sync/cold-start.ts';
|
|
33
|
+
import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
|
|
34
|
+
import { migrateSeed } from '../sync/migrate-seed.ts';
|
|
35
|
+
|
|
36
|
+
/** The exact serialization `strokesToSvg([])` emits — the eraser's payload. */
|
|
37
|
+
const EMPTY_WRAPPER = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>';
|
|
38
|
+
const STROKES =
|
|
39
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><image data-id="s_1" data-tool="image" x="0" y="0" width="160" height="160" href="assets/0327a8e5.png"/></svg>';
|
|
40
|
+
const STROKES_B =
|
|
41
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_2" x="1" y="1" width="10" height="10"/></svg>';
|
|
42
|
+
|
|
43
|
+
/* ------------------------------------------------- isEmptyAnnotationsSvg */
|
|
44
|
+
|
|
45
|
+
describe('isEmptyAnnotationsSvg', () => {
|
|
46
|
+
test('null / empty / whitespace are empty', () => {
|
|
47
|
+
expect(isEmptyAnnotationsSvg(null)).toBe(true);
|
|
48
|
+
expect(isEmptyAnnotationsSvg('')).toBe(true);
|
|
49
|
+
expect(isEmptyAnnotationsSvg(' \n\t ')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the bare wrapper is empty — the eraser regression pin', () => {
|
|
53
|
+
expect(isEmptyAnnotationsSvg(EMPTY_WRAPPER)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('wrapper with surrounding whitespace is empty', () => {
|
|
57
|
+
expect(isEmptyAnnotationsSvg(` ${EMPTY_WRAPPER}\n`)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a single stroke is NOT empty', () => {
|
|
61
|
+
expect(isEmptyAnnotationsSvg(STROKES)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/* ------------------------------------------- decideAnnotationsColdStart */
|
|
66
|
+
|
|
67
|
+
function input(over: Partial<AnnotationsColdStartInput>): AnnotationsColdStartInput {
|
|
68
|
+
return {
|
|
69
|
+
local: null,
|
|
70
|
+
doc: '',
|
|
71
|
+
isEmpty: isEmptyAnnotationsSvg,
|
|
72
|
+
localMtimeMs: null,
|
|
73
|
+
docEditAtMs: null,
|
|
74
|
+
bodyWinner: 'hub',
|
|
75
|
+
...over,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('decideAnnotationsColdStart — emptiness vs content', () => {
|
|
80
|
+
test('both empty → none', () => {
|
|
81
|
+
expect(decideAnnotationsColdStart(input({})).winner).toBe('none');
|
|
82
|
+
expect(
|
|
83
|
+
decideAnnotationsColdStart(input({ local: EMPTY_WRAPPER, doc: EMPTY_WRAPPER })).winner
|
|
84
|
+
).toBe('none');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('THE ERASER: unstamped hub wrapper + local strokes + hub body winner → local', () => {
|
|
88
|
+
const d = decideAnnotationsColdStart(
|
|
89
|
+
input({ local: STROKES, doc: EMPTY_WRAPPER, localMtimeMs: 1000, bodyWinner: 'hub' })
|
|
90
|
+
);
|
|
91
|
+
expect(d.winner).toBe('local');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('unset hub lane + local strokes → local (seed up), even with hub body winner', () => {
|
|
95
|
+
const d = decideAnnotationsColdStart(
|
|
96
|
+
input({ local: STROKES, doc: '', localMtimeMs: 1000, bodyWinner: 'hub' })
|
|
97
|
+
);
|
|
98
|
+
expect(d.winner).toBe('local');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('STAMPED hub delete-all newer than local strokes → hub (deletes are honored)', () => {
|
|
102
|
+
const d = decideAnnotationsColdStart(
|
|
103
|
+
input({ local: STROKES, doc: EMPTY_WRAPPER, localMtimeMs: 1000, docEditAtMs: 2000 })
|
|
104
|
+
);
|
|
105
|
+
expect(d.winner).toBe('hub');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('stamped hub delete-all OLDER than local strokes → local', () => {
|
|
109
|
+
const d = decideAnnotationsColdStart(
|
|
110
|
+
input({ local: STROKES, doc: EMPTY_WRAPPER, localMtimeMs: 2000, docEditAtMs: 1000 })
|
|
111
|
+
);
|
|
112
|
+
expect(d.winner).toBe('local');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('local absent + hub strokes → hub (clean first sync materializes)', () => {
|
|
116
|
+
const d = decideAnnotationsColdStart(input({ doc: STROKES }));
|
|
117
|
+
expect(d.winner).toBe('hub');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('local delete-all (wrapper on disk) newer than hub stamp → local', () => {
|
|
121
|
+
const d = decideAnnotationsColdStart(
|
|
122
|
+
input({ local: EMPTY_WRAPPER, doc: STROKES, localMtimeMs: 2000, docEditAtMs: 1000 })
|
|
123
|
+
);
|
|
124
|
+
expect(d.winner).toBe('local');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('local wrapper with no doc stamp → hub (unstamped local emptiness loses too)', () => {
|
|
128
|
+
const d = decideAnnotationsColdStart(
|
|
129
|
+
input({ local: EMPTY_WRAPPER, doc: STROKES, localMtimeMs: 2000 })
|
|
130
|
+
);
|
|
131
|
+
expect(d.winner).toBe('hub');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('decideAnnotationsColdStart — both non-empty', () => {
|
|
136
|
+
test('equal content → none', () => {
|
|
137
|
+
const d = decideAnnotationsColdStart(input({ local: STROKES, doc: STROKES }));
|
|
138
|
+
expect(d.winner).toBe('none');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('newest wins by per-lane stamp: local newer → local', () => {
|
|
142
|
+
const d = decideAnnotationsColdStart(
|
|
143
|
+
input({ local: STROKES, doc: STROKES_B, localMtimeMs: 2000, docEditAtMs: 1000 })
|
|
144
|
+
);
|
|
145
|
+
expect(d.winner).toBe('local');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('newest wins by per-lane stamp: doc newer → hub', () => {
|
|
149
|
+
const d = decideAnnotationsColdStart(
|
|
150
|
+
input({ local: STROKES, doc: STROKES_B, localMtimeMs: 1000, docEditAtMs: 2000 })
|
|
151
|
+
);
|
|
152
|
+
expect(d.winner).toBe('hub');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('no per-lane stamp → follows the body winner (legacy coupling)', () => {
|
|
156
|
+
expect(
|
|
157
|
+
decideAnnotationsColdStart(
|
|
158
|
+
input({ local: STROKES, doc: STROKES_B, localMtimeMs: 1000, bodyWinner: 'local' })
|
|
159
|
+
).winner
|
|
160
|
+
).toBe('local');
|
|
161
|
+
expect(
|
|
162
|
+
decideAnnotationsColdStart(
|
|
163
|
+
input({ local: STROKES, doc: STROKES_B, localMtimeMs: 1000, bodyWinner: 'hub' })
|
|
164
|
+
).winner
|
|
165
|
+
).toBe('hub');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/* ------------------------------------------------------- codec round-trip */
|
|
170
|
+
|
|
171
|
+
describe('stampAnnotationsEdit / annotationsEditAtFromDoc', () => {
|
|
172
|
+
test('round-trips; absent stamp reads null', () => {
|
|
173
|
+
const doc = new Y.Doc();
|
|
174
|
+
expect(annotationsEditAtFromDoc(doc)).toBe(null);
|
|
175
|
+
stampAnnotationsEdit(doc, undefined, 12345);
|
|
176
|
+
expect(annotationsEditAtFromDoc(doc)).toBe(12345);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------ agent integration */
|
|
181
|
+
|
|
182
|
+
let dir: string;
|
|
183
|
+
let agent: CanvasSyncAgent;
|
|
184
|
+
let docA: Y.Doc;
|
|
185
|
+
let docB: Y.Doc;
|
|
186
|
+
|
|
187
|
+
function paths() {
|
|
188
|
+
return {
|
|
189
|
+
html: join(dir, 'screen.html'),
|
|
190
|
+
comments: join(dir, '_comments', 'screen.json'),
|
|
191
|
+
annotations: join(dir, 'screen.annotations.svg'),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
dir = mkdtempSync(join(tmpdir(), 'sync-ann-cold-'));
|
|
197
|
+
docA = new Y.Doc();
|
|
198
|
+
docB = new Y.Doc();
|
|
199
|
+
const TRANSPORT = Symbol('transport');
|
|
200
|
+
docA.on('update', (update: Uint8Array, origin: unknown) => {
|
|
201
|
+
if (origin === TRANSPORT) return;
|
|
202
|
+
Y.applyUpdate(docB, update, TRANSPORT);
|
|
203
|
+
});
|
|
204
|
+
docB.on('update', (update: Uint8Array, origin: unknown) => {
|
|
205
|
+
if (origin === TRANSPORT) return;
|
|
206
|
+
Y.applyUpdate(docA, update, TRANSPORT);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
agent?.stop();
|
|
212
|
+
rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
function makeAgent(): CanvasSyncAgent {
|
|
216
|
+
const a = createCanvasSyncAgent({
|
|
217
|
+
slug: 'screen',
|
|
218
|
+
doc: docB,
|
|
219
|
+
paths: paths(),
|
|
220
|
+
echoGuard: createEchoGuard(),
|
|
221
|
+
flushMs: 0,
|
|
222
|
+
});
|
|
223
|
+
a.start();
|
|
224
|
+
return a;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe('cold start — the eraser scenario end to end', () => {
|
|
228
|
+
test('unstamped hub wrapper does NOT erase local strokes; local seeds up + stamps', async () => {
|
|
229
|
+
// Hub holds the stale empty wrapper (what the eraser propagated) with no
|
|
230
|
+
// per-lane stamp — the exact pre-fix state of every wiped canvas.
|
|
231
|
+
docA.getMap('annotations').set('svg', EMPTY_WRAPPER);
|
|
232
|
+
// Local disk holds real strokes with an image reference.
|
|
233
|
+
writeFileSync(paths().annotations, STROKES);
|
|
234
|
+
|
|
235
|
+
agent = makeAgent();
|
|
236
|
+
await agent.reconcile();
|
|
237
|
+
|
|
238
|
+
// Disk keeps the strokes; the doc now carries them, stamped.
|
|
239
|
+
expect(readFileSync(paths().annotations, 'utf8')).toBe(STROKES);
|
|
240
|
+
expect(annotationsFromDoc(docB)).toBe(STROKES);
|
|
241
|
+
expect(annotationsFromDoc(docA)).toBe(STROKES); // propagated to the hub side
|
|
242
|
+
expect(annotationsEditAtFromDoc(docB)).not.toBe(null);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('stamped hub delete-all newer than local strokes IS honored on disk', async () => {
|
|
246
|
+
writeFileSync(paths().annotations, STROKES);
|
|
247
|
+
// Backdate the local file so the hub's delete-all stamp is provably newer.
|
|
248
|
+
const past = new Date(Date.now() - 60_000);
|
|
249
|
+
utimesSync(paths().annotations, past, past);
|
|
250
|
+
docA.transact(() => {
|
|
251
|
+
docA.getMap('annotations').set('svg', EMPTY_WRAPPER);
|
|
252
|
+
});
|
|
253
|
+
stampAnnotationsEdit(docA, undefined, Date.now());
|
|
254
|
+
|
|
255
|
+
agent = makeAgent();
|
|
256
|
+
await agent.reconcile();
|
|
257
|
+
|
|
258
|
+
expect(readFileSync(paths().annotations, 'utf8')).toBe(EMPTY_WRAPPER);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('clean first sync still materializes hub strokes to disk', async () => {
|
|
262
|
+
docA.getMap('annotations').set('svg', STROKES);
|
|
263
|
+
|
|
264
|
+
agent = makeAgent();
|
|
265
|
+
await agent.reconcile();
|
|
266
|
+
|
|
267
|
+
expect(readFileSync(paths().annotations, 'utf8')).toBe(STROKES);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('SHARED-DOC eraser: migrateSeed rescues local strokes from an unstamped hub wrapper', async () => {
|
|
271
|
+
// The cell-side copy of the eraser: hub doc has a newer body (so the body
|
|
272
|
+
// resolution keeps the hub) and a stale unstamped wrapper in the
|
|
273
|
+
// annotations lane; local disk has real strokes.
|
|
274
|
+
const doc = new Y.Doc();
|
|
275
|
+
applyHtmlToDoc(doc, '<main>hub body</main>');
|
|
276
|
+
doc.getMap('annotations').set('svg', EMPTY_WRAPPER);
|
|
277
|
+
writeFileSync(paths().html, '<main>hub body</main>'); // body identical → noop path
|
|
278
|
+
writeFileSync(paths().annotations, STROKES);
|
|
279
|
+
|
|
280
|
+
await migrateSeed({ slug: 'screen', doc, paths: paths() });
|
|
281
|
+
|
|
282
|
+
// The doc now carries the local strokes, stamped — so the collab room's
|
|
283
|
+
// persistJson materializes strokes, not the wrapper.
|
|
284
|
+
expect(annotationsFromDoc(doc)).toBe(STROKES);
|
|
285
|
+
expect(annotationsEditAtFromDoc(doc)).not.toBe(null);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('applyFromFs stamps annotationsEditAt in the same update', () => {
|
|
289
|
+
agent = makeAgent();
|
|
290
|
+
const bytes = new TextEncoder().encode(STROKES);
|
|
291
|
+
const changed = agent.applyFromFs({
|
|
292
|
+
path: paths().annotations,
|
|
293
|
+
bytes,
|
|
294
|
+
hash: hashBytes(STROKES),
|
|
295
|
+
});
|
|
296
|
+
expect(changed).toBe(true);
|
|
297
|
+
expect(annotationsFromDoc(docB)).toBe(STROKES);
|
|
298
|
+
expect(annotationsEditAtFromDoc(docB)).not.toBe(null);
|
|
299
|
+
// The stamp crossed to the other peer with the same update.
|
|
300
|
+
expect(annotationsEditAtFromDoc(docA)).not.toBe(null);
|
|
301
|
+
});
|
|
302
|
+
});
|