@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.
- package/apps/studio/api.ts +68 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-lib.tsx +16 -3
- package/apps/studio/canvas-shell.tsx +34 -39
- package/apps/studio/client/app.jsx +43 -9
- package/apps/studio/client/panels/SyncPanel.jsx +27 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +707 -707
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/http.ts +12 -1
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +198 -10
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +52 -2
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/whats-new.json +18 -0
- 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 —
|
|
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
|
-
|
|
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', () => {
|