@1agh/maude 0.60.3 → 0.60.5

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.
Files changed (43) hide show
  1. package/apps/studio/api.ts +68 -5
  2. package/apps/studio/build.ts +10 -0
  3. package/apps/studio/canvas-lib.tsx +16 -3
  4. package/apps/studio/canvas-shell.tsx +34 -39
  5. package/apps/studio/client/app.jsx +43 -9
  6. package/apps/studio/client/panels/SyncPanel.jsx +27 -0
  7. package/apps/studio/context.ts +9 -0
  8. package/apps/studio/dist/client.bundle.js +707 -707
  9. package/apps/studio/dist/comment-mount.js +2 -2
  10. package/apps/studio/dom-selection.ts +67 -0
  11. package/apps/studio/http.ts +12 -1
  12. package/apps/studio/input-router.tsx +26 -2
  13. package/apps/studio/sync/agent.ts +64 -13
  14. package/apps/studio/sync/asset-pull.ts +210 -0
  15. package/apps/studio/sync/asset-push.ts +111 -98
  16. package/apps/studio/sync/codec.ts +47 -0
  17. package/apps/studio/sync/cold-start.ts +96 -0
  18. package/apps/studio/sync/file-membership.ts +290 -0
  19. package/apps/studio/sync/file-pull.ts +330 -0
  20. package/apps/studio/sync/index.ts +198 -10
  21. package/apps/studio/sync/migrate-seed.ts +52 -7
  22. package/apps/studio/sync/projection.ts +10 -1
  23. package/apps/studio/sync/remote-docs.ts +98 -4
  24. package/apps/studio/sync/status.ts +25 -0
  25. package/apps/studio/sync/tombstone-apply.ts +131 -0
  26. package/apps/studio/test/canvas-meta-api.test.ts +114 -3
  27. package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
  28. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  29. package/apps/studio/test/dom-selection.test.ts +69 -1
  30. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  31. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  32. package/apps/studio/test/input-router.test.ts +69 -0
  33. package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
  34. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  35. package/apps/studio/test/sync-asset-push.test.ts +52 -2
  36. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  37. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  38. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  39. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  40. package/apps/studio/test/sync-status.test.ts +18 -0
  41. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  42. package/apps/studio/whats-new.json +18 -0
  43. package/package.json +8 -8
@@ -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
+ });
@@ -0,0 +1,161 @@
1
+ // The downward asset lane — see sync/asset-pull.ts.
2
+ //
3
+ // Regression cover for the reported "obrázky v annotations se nevykreslí":
4
+ // `<slug>.annotations.svg` synced, its `assets/<sha8>.png` did not, and the
5
+ // canvas rendered a broken-image glyph on every peer but the one that made it.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { pullAssets, referencedAssets } from '../sync/asset-pull.ts';
13
+
14
+ function project(files: Record<string, string>): string {
15
+ const root = mkdtempSync(path.join(tmpdir(), 'maude-asset-pull-'));
16
+ for (const [rel, body] of Object.entries(files)) {
17
+ const abs = path.join(root, rel);
18
+ mkdirSync(path.dirname(abs), { recursive: true });
19
+ writeFileSync(abs, body);
20
+ }
21
+ return root;
22
+ }
23
+
24
+ const annotation = (name: string) =>
25
+ `<svg xmlns="http://www.w3.org/2000/svg"><image data-tool="image" href="assets/${name}"/></svg>`;
26
+
27
+ /** A hub that serves every asset as three bytes. */
28
+ const servingFetch = (seen: string[] = []) =>
29
+ (async (url: string) => {
30
+ seen.push(String(url));
31
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
32
+ }) as unknown as typeof fetch;
33
+
34
+ describe('referencedAssets', () => {
35
+ test('finds the image an annotation references', () => {
36
+ const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
37
+ expect(referencedAssets(root)).toEqual(['c0fa9c7f.png']);
38
+ });
39
+
40
+ test('reads canvas bodies and sidecars too, deduplicating', () => {
41
+ const root = project({
42
+ 'ui-board.annotations.svg': annotation('a.png'),
43
+ 'ui/card.tsx': `<img src="assets/b.jpg" /><img src="assets/a.png" />`,
44
+ 'ui/card.css': `.x { background: url(assets/c.webp) }`,
45
+ });
46
+ expect(referencedAssets(root)).toEqual(['a.png', 'b.jpg', 'c.webp']);
47
+ });
48
+
49
+ test('refuses a reference that could place a file outside assets/', () => {
50
+ // An annotation can itself have arrived from the hub, so a name found in
51
+ // one is untrusted input.
52
+ const root = project({
53
+ 'ui-board.annotations.svg': [
54
+ '<svg>',
55
+ '<image href="assets/../../etc/passwd"/>',
56
+ '<image href="assets/ok.png"/>',
57
+ '</svg>',
58
+ ].join(''),
59
+ });
60
+ expect(referencedAssets(root)).toEqual(['ok.png']);
61
+ });
62
+
63
+ test('refuses an extension outside the asset allowlist', () => {
64
+ const root = project({
65
+ 'ui/card.tsx': `"assets/payload.sh" "assets/x.tsx" "assets/real.png"`,
66
+ });
67
+ expect(referencedAssets(root)).toEqual(['real.png']);
68
+ });
69
+
70
+ test('ignores runtime directories — a trashed canvas wants nothing', () => {
71
+ const root = project({
72
+ '_trash/old-deleted-1/old.annotations.svg': annotation('ghost.png'),
73
+ '_history/ui-board/1.tsx': `"assets/older.png"`,
74
+ 'ui-board.annotations.svg': annotation('live.png'),
75
+ });
76
+ expect(referencedAssets(root)).toEqual(['live.png']);
77
+ });
78
+ });
79
+
80
+ describe('pullAssets', () => {
81
+ const opts = (root: string, fetchImpl: typeof fetch) => ({
82
+ designRoot: root,
83
+ hubUrl: 'https://hub.example/',
84
+ token: () => 'tok',
85
+ fetchImpl,
86
+ log: { log: () => {}, warn: () => {} },
87
+ });
88
+
89
+ test('fetches a referenced asset this machine does not have', async () => {
90
+ const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
91
+ const seen: string[] = [];
92
+ const res = await pullAssets(opts(root, servingFetch(seen)));
93
+
94
+ expect(res.pulled).toEqual(['c0fa9c7f.png']);
95
+ expect(seen).toEqual(['https://hub.example/assets/c0fa9c7f.png']);
96
+ expect(readFileSync(path.join(root, 'assets/c0fa9c7f.png'))).toEqual(Buffer.from([1, 2, 3]));
97
+ });
98
+
99
+ test('asks for nothing when every reference is already on disk', async () => {
100
+ const root = project({
101
+ 'ui-board.annotations.svg': annotation('have.png'),
102
+ 'assets/have.png': 'bytes',
103
+ });
104
+ let called = 0;
105
+ const counting = (async () => {
106
+ called += 1;
107
+ return new Response('x', { status: 200 });
108
+ }) as unknown as typeof fetch;
109
+
110
+ const res = await pullAssets(opts(root, counting));
111
+ expect(called).toBe(0);
112
+ expect(res.present).toBe(1);
113
+ expect(res.pulled).toEqual([]);
114
+ });
115
+
116
+ test('is idempotent — a second pass costs no requests', async () => {
117
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
118
+ await pullAssets(opts(root, servingFetch()));
119
+ const seen: string[] = [];
120
+ const second = await pullAssets(opts(root, servingFetch(seen)));
121
+ expect(seen).toEqual([]);
122
+ expect(second.pulled).toEqual([]);
123
+ });
124
+
125
+ test('a 404 is recorded and retried, never fatal', async () => {
126
+ // Ordinary: the peer that owns the bytes has not pushed them yet.
127
+ const root = project({ 'ui-board.annotations.svg': annotation('later.png') });
128
+ const missing = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
129
+ const res = await pullAssets(opts(root, missing));
130
+ expect(res.pulled).toEqual([]);
131
+ expect(res.failed).toEqual([{ name: 'later.png', reason: 'HTTP 404' }]);
132
+ expect(existsSync(path.join(root, 'assets/later.png'))).toBe(false);
133
+ });
134
+
135
+ test('a throwing hub is not fatal either', async () => {
136
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
137
+ const boom = (async () => {
138
+ throw new Error('offline');
139
+ }) as unknown as typeof fetch;
140
+ const res = await pullAssets(opts(root, boom));
141
+ expect(res.pulled).toEqual([]);
142
+ expect(res.failed[0]?.reason).toBe('offline');
143
+ });
144
+
145
+ test('refuses an empty body rather than writing a zero-byte image', async () => {
146
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
147
+ const empty = (async () =>
148
+ new Response(new Uint8Array([]), { status: 200 })) as unknown as typeof fetch;
149
+ const res = await pullAssets(opts(root, empty));
150
+ expect(res.pulled).toEqual([]);
151
+ expect(existsSync(path.join(root, 'assets/x.png'))).toBe(false);
152
+ });
153
+
154
+ test('leaves no .part file behind on success', async () => {
155
+ // The write is temp-then-rename so the dev server serving this very path
156
+ // never reads a half-written image.
157
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
158
+ await pullAssets(opts(root, servingFetch()));
159
+ expect(existsSync(path.join(root, 'assets/x.png.part'))).toBe(false);
160
+ });
161
+ });
@@ -21,7 +21,7 @@ function scratchDesignRoot(): string {
21
21
  return mkdtempSync(join(tmpdir(), 'asset-push-'));
22
22
  }
23
23
 
24
- describe('listPushableAssets — every assets/ dir, asset extensions only', () => {
24
+ describe('listPushableAssets — classifier membership over the whole design root', () => {
25
25
  test('finds top-level content-addressed AND nested DS/brand assets', () => {
26
26
  const designRoot = scratchDesignRoot();
27
27
  // Class 1 — top-level content-addressed uploads (+ a .photo.json sidecar).
@@ -67,6 +67,51 @@ describe('listPushableAssets — every assets/ dir, asset extensions only', () =
67
67
  writeFileSync(join(designRoot, 'assets/a/b/c/d/e/f/g/x.png'), 'x');
68
68
  expect(listPushableAssets(designRoot)).toEqual([]);
69
69
  });
70
+
71
+ test('the file-plane widening: the RCA miss-list goes up, canvas lanes stay home', () => {
72
+ // feature-sync-file-plane — the sweep now carries what the fresh-link RCA
73
+ // named laneless: token stylesheets, docs, shared modules, underscore
74
+ // FILES. A canvas body and its named sidecars stay the CRDT lanes'.
75
+ const designRoot = scratchDesignRoot();
76
+ mkdirSync(join(designRoot, 'system/ds/preview'), { recursive: true });
77
+ writeFileSync(join(designRoot, 'config.json'), '{}');
78
+ writeFileSync(join(designRoot, 'system/ds/brand.css'), 'x');
79
+ writeFileSync(join(designRoot, 'system/ds/README.md'), 'x');
80
+ writeFileSync(join(designRoot, 'system/ds/preview/_brand-css.ts'), 'x');
81
+ writeFileSync(join(designRoot, 'system/ds/preview/_layout.css'), 'x');
82
+ writeFileSync(join(designRoot, 'system/ds/preview/specimen.tsx'), 'x');
83
+ writeFileSync(join(designRoot, 'system/ds/preview/specimen.css'), 'x'); // sibling css lane
84
+ writeFileSync(join(designRoot, 'system/ds/preview/specimen.meta.json'), '{}');
85
+ expect(listPushableAssets(designRoot, { syncFiles: true })).toEqual([
86
+ 'system/ds/README.md',
87
+ 'system/ds/brand.css',
88
+ 'system/ds/preview/_brand-css.ts',
89
+ 'system/ds/preview/_layout.css',
90
+ ]);
91
+ // Flag OFF ⇒ today's reach, byte-for-byte: none of these are binary
92
+ // media under an assets/ dir, so none of them move.
93
+ expect(listPushableAssets(designRoot)).toEqual([]);
94
+ });
95
+
96
+ test('declared canvasGroups decide what a canvas body IS', () => {
97
+ const designRoot = scratchDesignRoot();
98
+ mkdirSync(join(designRoot, 'mocks'), { recursive: true });
99
+ mkdirSync(join(designRoot, 'lib'), { recursive: true });
100
+ writeFileSync(join(designRoot, 'mocks/screen.tsx'), 'x');
101
+ writeFileSync(join(designRoot, 'lib/helpers.tsx'), 'x');
102
+ const groups = [{ path: 'mocks' }];
103
+ // `mocks/screen.tsx` is a canvas here; `lib/helpers.tsx` a shared module.
104
+ expect(listPushableAssets(designRoot, { canvasGroups: groups, syncFiles: true })).toEqual([
105
+ 'lib/helpers.tsx',
106
+ ]);
107
+ // …and both the groups and the flag reach the walk through config.json —
108
+ // the out-of-process worker's path.
109
+ writeFileSync(
110
+ join(designRoot, 'config.json'),
111
+ JSON.stringify({ canvasGroups: groups, linkedHub: { url: 'http://h', syncFiles: true } })
112
+ );
113
+ expect(listPushableAssets(designRoot)).toEqual(['lib/helpers.tsx']);
114
+ });
70
115
  });
71
116
 
72
117
  describe('pushAssets — routes each class to the right hub endpoint', () => {
@@ -790,7 +835,12 @@ describe('the runtime sweeps on change, not only at boot', () => {
790
835
  const SYNC = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
791
836
 
792
837
  test('the fs watcher decides with isPushableAssetRel and schedules a sweep', () => {
793
- expect(SYNC).toContain('if (isPushableAssetRel(rel)) scheduleAssetSweep(');
838
+ // The predicate gets the project's OWN canvas groups — with the default
839
+ // set a custom-group project's shared module would answer canvas-owned
840
+ // and silently never schedule the sweep that uploads it.
841
+ expect(SYNC).toContain(
842
+ 'if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep('
843
+ );
794
844
  });
795
845
 
796
846
  test('bursts coalesce, and a change DURING a sweep is not lost', () => {