@1agh/maude 0.58.0 → 0.58.2
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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +107 -33
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +899 -899
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/index.ts +490 -17
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-path-pull.test.ts +402 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +118 -0
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// Pulling a project into an EMPTY folder, through the real sync runtime.
|
|
2
|
+
//
|
|
3
|
+
// The unit tests around `canvas-path.ts` prove the rules; `apps/hub/test/`
|
|
4
|
+
// proves the two receivers. This proves the wiring between them on the desktop
|
|
5
|
+
// side — that the path is read from the DOCUMENT (not the listing, which cannot
|
|
6
|
+
// carry it), that the agent is built around the corrected path rather than the
|
|
7
|
+
// provisional one, that the canvas is written where the tree will find it, and
|
|
8
|
+
// that a folder with no `config.json` ends up with one describing what arrived.
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
symlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
24
|
+
import * as Y from 'yjs';
|
|
25
|
+
|
|
26
|
+
import type { Context, DevServerConfig } from '../context.ts';
|
|
27
|
+
import { createBus } from '../context.ts';
|
|
28
|
+
import { createSyncRuntime, type SyncProvider } from '../sync/index.ts';
|
|
29
|
+
|
|
30
|
+
const URL_ = 'https://hub.example.com';
|
|
31
|
+
const NESTED_REL = 'ui/2026/social/summer-camp.tsx';
|
|
32
|
+
const NESTED_SLUG = 'ui-2026-social-summer-camp';
|
|
33
|
+
const BODY = 'export default () => <main>summer camp</main>;\n';
|
|
34
|
+
|
|
35
|
+
let dir: string;
|
|
36
|
+
let cfgPathEnv: string | undefined;
|
|
37
|
+
let realFetch: typeof fetch;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
dir = mkdtempSync(join(tmpdir(), 'sync-path-pull-'));
|
|
41
|
+
cfgPathEnv = process.env.HUBS_CONFIG_PATH;
|
|
42
|
+
process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
|
|
43
|
+
writeFileSync(
|
|
44
|
+
process.env.HUBS_CONFIG_PATH,
|
|
45
|
+
JSON.stringify({ hubs: { [URL_]: { token: 'mau_test', linkedAt: 1 } } })
|
|
46
|
+
);
|
|
47
|
+
chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
|
|
48
|
+
realFetch = globalThis.fetch;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
globalThis.fetch = realFetch;
|
|
53
|
+
if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
|
|
54
|
+
else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The hub's `GET /api/documents` — names and byte counts, never a path. */
|
|
59
|
+
function hubListing(documents: Array<{ name: string; bytes: number }>): void {
|
|
60
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
61
|
+
if (String(input).endsWith('/api/documents')) {
|
|
62
|
+
return new Response(JSON.stringify({ documents }), {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return new Response('nope', { status: 404 });
|
|
68
|
+
}) as typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeCtx(canvasGroups?: DevServerConfig['canvasGroups']): Context {
|
|
72
|
+
const designRoot = join(dir, 'design');
|
|
73
|
+
mkdirSync(join(designRoot, '_comments'), { recursive: true });
|
|
74
|
+
return {
|
|
75
|
+
canvasOrigin: 'http://canvas.localhost:1234',
|
|
76
|
+
cfg: {
|
|
77
|
+
name: 'test',
|
|
78
|
+
projectLabel: null,
|
|
79
|
+
designRoot: 'design',
|
|
80
|
+
canvasGroups: canvasGroups ?? [
|
|
81
|
+
{ label: 'Design system', path: 'system' },
|
|
82
|
+
{ label: 'Canvases', path: 'ui' },
|
|
83
|
+
],
|
|
84
|
+
rootClass: 'app',
|
|
85
|
+
themeDefault: 'dark',
|
|
86
|
+
tokensCssRel: 'system/colors.css',
|
|
87
|
+
teamAccentDefault: null,
|
|
88
|
+
handoffTargets: [],
|
|
89
|
+
newCanvasDir: 'ui',
|
|
90
|
+
newComponentDir: 'ui/components',
|
|
91
|
+
linkedHub: { url: URL_, linkedAt: 1 },
|
|
92
|
+
_source: 'defaults',
|
|
93
|
+
},
|
|
94
|
+
projectLabel: 'test',
|
|
95
|
+
paths: {
|
|
96
|
+
repoRoot: dir,
|
|
97
|
+
designRel: 'design',
|
|
98
|
+
designRoot,
|
|
99
|
+
serverInfoFile: join(designRoot, '_server.json'),
|
|
100
|
+
activeFile: join(designRoot, '_active.json'),
|
|
101
|
+
commentsDir: join(designRoot, '_comments'),
|
|
102
|
+
canvasStateDir: join(designRoot, '_canvas-state'),
|
|
103
|
+
historyDir: join(designRoot, '_history'),
|
|
104
|
+
tokensUrlRel: 'design/system/colors.css',
|
|
105
|
+
systemDirRel: 'system',
|
|
106
|
+
},
|
|
107
|
+
bus: createBus(),
|
|
108
|
+
} as unknown as Context;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A provider whose document ALREADY holds what the hub holds — the body plus
|
|
113
|
+
* whatever `syncMeta.path` the peer that authored it stamped. That is the shape
|
|
114
|
+
* a real pull sees at `onceSynced`, and the reason the target cannot be decided
|
|
115
|
+
* from the listing.
|
|
116
|
+
*/
|
|
117
|
+
function hubDocProviderFactory(
|
|
118
|
+
seed: Record<string, { body: string; path?: string; css?: string }>,
|
|
119
|
+
docs?: Map<string, Y.Doc>
|
|
120
|
+
) {
|
|
121
|
+
return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
|
|
122
|
+
const doc = args.document ?? new Y.Doc();
|
|
123
|
+
const slug = args.documentName.split('/').pop() as string;
|
|
124
|
+
const entry = seed[slug];
|
|
125
|
+
if (entry) {
|
|
126
|
+
doc.transact(() => {
|
|
127
|
+
doc.getText('html').insert(0, entry.body);
|
|
128
|
+
if (entry.path) doc.getMap('syncMeta').set('path', entry.path);
|
|
129
|
+
if (entry.css) doc.getText('css').insert(0, entry.css);
|
|
130
|
+
}, 'hub');
|
|
131
|
+
}
|
|
132
|
+
docs?.set(slug, doc);
|
|
133
|
+
return {
|
|
134
|
+
document: doc,
|
|
135
|
+
awareness: new Awareness(doc),
|
|
136
|
+
async onceSynced() {},
|
|
137
|
+
destroy() {},
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('a peer with an empty design root pulls the project down whole', () => {
|
|
143
|
+
test('the canvas lands at the path its author gave it', async () => {
|
|
144
|
+
hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
|
|
145
|
+
const ctx = makeCtx();
|
|
146
|
+
const runtime = createSyncRuntime(ctx, {
|
|
147
|
+
providerFactory: hubDocProviderFactory({
|
|
148
|
+
[NESTED_SLUG]: { body: BODY, path: NESTED_REL },
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
await runtime?.start();
|
|
152
|
+
await runtime?.stop();
|
|
153
|
+
|
|
154
|
+
const wanted = join(ctx.paths.designRoot, NESTED_REL);
|
|
155
|
+
expect(existsSync(wanted)).toBe(true);
|
|
156
|
+
expect(readFileSync(wanted, 'utf8')).toBe(BODY);
|
|
157
|
+
// And NOT flat at the design root, where nothing would list it.
|
|
158
|
+
expect(existsSync(join(ctx.paths.designRoot, `${NESTED_SLUG}.tsx`))).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a document with NO path still arrives, inside a canvas group', async () => {
|
|
162
|
+
hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
|
|
163
|
+
const ctx = makeCtx();
|
|
164
|
+
const runtime = createSyncRuntime(ctx, {
|
|
165
|
+
providerFactory: hubDocProviderFactory({
|
|
166
|
+
'ui-legacy': { body: 'export default () => null;\n' },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
await runtime?.start();
|
|
170
|
+
await runtime?.stop();
|
|
171
|
+
|
|
172
|
+
// `ui/legacy.tsx`, not `ui/ui-legacy.tsx` — the latter would be visible and
|
|
173
|
+
// would slug to a DIFFERENT document.
|
|
174
|
+
expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a hostile path is refused and the canvas still arrives', async () => {
|
|
178
|
+
hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
|
|
179
|
+
const ctx = makeCtx();
|
|
180
|
+
const runtime = createSyncRuntime(ctx, {
|
|
181
|
+
providerFactory: hubDocProviderFactory({
|
|
182
|
+
'ui-legacy': { body: 'x\n', path: '../../../../tmp/pwned.tsx' },
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
await runtime?.start();
|
|
186
|
+
await runtime?.stop();
|
|
187
|
+
|
|
188
|
+
expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
|
|
189
|
+
expect(existsSync(join(dir, '..', 'tmp', 'pwned.tsx'))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a fresh link accepts the author’s OWN group, and writes it down', async () => {
|
|
193
|
+
// `config.json` is not part of the sync lane, so a bare folder runs on the
|
|
194
|
+
// DEFAULT groups. Without the fresh-link relaxation a project whose author
|
|
195
|
+
// calls their group `screens` would have every path refused and arrive
|
|
196
|
+
// invisible — the empty-folder case, in the shape it actually takes.
|
|
197
|
+
hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
|
|
198
|
+
const ctx = makeCtx();
|
|
199
|
+
const runtime = createSyncRuntime(ctx, {
|
|
200
|
+
providerFactory: hubDocProviderFactory({
|
|
201
|
+
'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
await runtime?.start();
|
|
205
|
+
await runtime?.stop();
|
|
206
|
+
|
|
207
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(true);
|
|
208
|
+
// …and the project now declares itself, so the next boot needs no relaxation.
|
|
209
|
+
const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
|
|
210
|
+
expect(cfg.canvasGroups.map((g: { path: string }) => g.path)).toContain('screens');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a project that already declared itself is NEVER overwritten', async () => {
|
|
214
|
+
const ctx = makeCtx();
|
|
215
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
216
|
+
const cfgFile = join(ctx.paths.designRoot, 'config.json');
|
|
217
|
+
const mine = `${JSON.stringify({ name: 'mine', canvasGroups: [{ label: 'UI', path: 'ui' }] })}\n`;
|
|
218
|
+
writeFileSync(cfgFile, mine);
|
|
219
|
+
hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
|
|
220
|
+
|
|
221
|
+
const runtime = createSyncRuntime(ctx, {
|
|
222
|
+
providerFactory: hubDocProviderFactory({
|
|
223
|
+
'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
await runtime?.start();
|
|
227
|
+
await runtime?.stop();
|
|
228
|
+
|
|
229
|
+
expect(readFileSync(cfgFile, 'utf8')).toBe(mine);
|
|
230
|
+
// The undeclared group is refused, so the canvas falls back — still arriving.
|
|
231
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(false);
|
|
232
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens-home.tsx'))).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("rule 7 governs a path's IDENTITY, not what already lives there", () => {
|
|
237
|
+
test('a pulled canvas never lands on a file that already exists locally', async () => {
|
|
238
|
+
// `scanCanvases` omits a canvas whose `.meta.json` says `syncable: false` —
|
|
239
|
+
// a security opt-out a hub must not be able to flip. Omitted means absent
|
|
240
|
+
// from `localCanvases`, which means the hub's document for that slug is
|
|
241
|
+
// classified hub-ONLY and pulled. Before the path lane that was benign: the
|
|
242
|
+
// body landed flat at the design root, loaded by nothing. Honouring a remote
|
|
243
|
+
// path would land it on the real file.
|
|
244
|
+
const ctx = makeCtx();
|
|
245
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
246
|
+
const guarded = join(ctx.paths.designRoot, 'ui', 'Card.tsx');
|
|
247
|
+
writeFileSync(guarded, 'export default () => <b>MINE</b>;\n');
|
|
248
|
+
writeFileSync(
|
|
249
|
+
join(ctx.paths.designRoot, 'ui', 'Card.meta.json'),
|
|
250
|
+
JSON.stringify({ syncable: false })
|
|
251
|
+
);
|
|
252
|
+
hubListing([{ name: 'ws/acme/main/ui-card', bytes: 10 }]);
|
|
253
|
+
|
|
254
|
+
const docs = new Map<string, Y.Doc>();
|
|
255
|
+
const runtime = createSyncRuntime(ctx, {
|
|
256
|
+
providerFactory: hubDocProviderFactory(
|
|
257
|
+
{ 'ui-card': { body: 'export default () => <b>HUB</b>;\n', path: 'ui/Card.tsx' } },
|
|
258
|
+
docs
|
|
259
|
+
),
|
|
260
|
+
});
|
|
261
|
+
await runtime?.start();
|
|
262
|
+
|
|
263
|
+
// The boot moment is not the whole story — the provider now OWNS a path, so
|
|
264
|
+
// every later hub edit flows to it. Push one, the way a hub actually would.
|
|
265
|
+
const doc = docs.get('ui-card');
|
|
266
|
+
if (doc) {
|
|
267
|
+
const text = doc.getText('html');
|
|
268
|
+
doc.transact(() => {
|
|
269
|
+
text.delete(0, text.length);
|
|
270
|
+
text.insert(0, 'export default () => <b>PWNED</b>;\n');
|
|
271
|
+
}, 'hub');
|
|
272
|
+
}
|
|
273
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
274
|
+
await runtime?.stop();
|
|
275
|
+
|
|
276
|
+
expect(readFileSync(guarded, 'utf8')).toContain('MINE');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('a pulled canvas cannot overwrite the served token stylesheet', async () => {
|
|
280
|
+
// `system` is a DEFAULT canvas group and the `.css` sibling is derived from
|
|
281
|
+
// the body path, so `system/colors_and_type.tsx` satisfies every rule and
|
|
282
|
+
// writes its css lane exactly where `tokensCssRel` is served from.
|
|
283
|
+
const ctx = makeCtx();
|
|
284
|
+
mkdirSync(join(ctx.paths.designRoot, 'system'), { recursive: true });
|
|
285
|
+
const tokens = join(ctx.paths.designRoot, 'system', 'colors.css');
|
|
286
|
+
writeFileSync(tokens, ':root{--bg-0:#fff}\n');
|
|
287
|
+
hubListing([{ name: 'ws/acme/main/system-colors', bytes: 10 }]);
|
|
288
|
+
|
|
289
|
+
const runtime = createSyncRuntime(ctx, {
|
|
290
|
+
providerFactory: hubDocProviderFactory({
|
|
291
|
+
'system-colors': {
|
|
292
|
+
body: 'x\n',
|
|
293
|
+
path: 'system/colors.tsx',
|
|
294
|
+
// The lane that does the damage — it is written to the body path's
|
|
295
|
+
// `.css` sibling, which here IS the served `tokensCssRel`.
|
|
296
|
+
css: ':root{--bg-0:url("https://evil.example/?x")}\n',
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
});
|
|
300
|
+
await runtime?.start();
|
|
301
|
+
await runtime?.stop();
|
|
302
|
+
|
|
303
|
+
expect(readFileSync(tokens, 'utf8')).toBe(':root{--bg-0:#fff}\n');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('containment is checked THROUGH symlinks, which resolve() cannot see', async () => {
|
|
307
|
+
const ctx = makeCtx();
|
|
308
|
+
const outside = mkdtempSync(join(tmpdir(), 'sync-path-outside-'));
|
|
309
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
310
|
+
symlinkSync(outside, join(ctx.paths.designRoot, 'ui', 'escape'), 'dir');
|
|
311
|
+
hubListing([{ name: 'ws/acme/main/ui-escape-pwned', bytes: 10 }]);
|
|
312
|
+
|
|
313
|
+
const runtime = createSyncRuntime(ctx, {
|
|
314
|
+
providerFactory: hubDocProviderFactory({
|
|
315
|
+
'ui-escape-pwned': { body: 'x\n', path: 'ui/escape/pwned.tsx' },
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
await runtime?.start();
|
|
319
|
+
await runtime?.stop();
|
|
320
|
+
|
|
321
|
+
expect(existsSync(join(outside, 'pwned.tsx'))).toBe(false);
|
|
322
|
+
rmSync(outside, { recursive: true, force: true });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('the untrusted markers name the path the body actually lands at', () => {
|
|
327
|
+
test('a nested pulled canvas is marked at its REAL path, not the provisional one', async () => {
|
|
328
|
+
// The markers are computed from the descriptor set BEFORE any document has
|
|
329
|
+
// synced — so every pulled entry is still the fallback — and the descriptors
|
|
330
|
+
// are then mutated in place by `relocatePulled`. Marking once left
|
|
331
|
+
// `_untrusted/INDEX.json` and the `.claudeignore` block naming a file that
|
|
332
|
+
// is never created, while the genuinely hub-pushed body sat elsewhere,
|
|
333
|
+
// listed nowhere. That is the DDR-054 §3 F3 control pointing at a phantom.
|
|
334
|
+
hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
|
|
335
|
+
const ctx = makeCtx();
|
|
336
|
+
const runtime = createSyncRuntime(ctx, {
|
|
337
|
+
providerFactory: hubDocProviderFactory({
|
|
338
|
+
[NESTED_SLUG]: { body: BODY, path: NESTED_REL },
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
await runtime?.start();
|
|
342
|
+
await runtime?.stop();
|
|
343
|
+
|
|
344
|
+
const index = readFileSync(join(ctx.paths.designRoot, '_untrusted', 'INDEX.json'), 'utf8');
|
|
345
|
+
expect(index).toContain('ui/2026/social/summer-camp.tsx');
|
|
346
|
+
// The provisional fallback was never created, so naming it is naming nothing.
|
|
347
|
+
expect(index).not.toContain('ui/2026-social-summer-camp.tsx');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('the fresh-link relaxation closes behind itself', () => {
|
|
352
|
+
test('only ONE undeclared group is ever learned', async () => {
|
|
353
|
+
// `freshLink` was a const, so after the first config was written every
|
|
354
|
+
// FURTHER undeclared group was accepted and appended — the relaxation
|
|
355
|
+
// perpetuating itself, and a hub free to plant directories all session.
|
|
356
|
+
hubListing([
|
|
357
|
+
{ name: 'ws/acme/main/screens-a', bytes: 10 },
|
|
358
|
+
{ name: 'ws/acme/main/vendor-b', bytes: 10 },
|
|
359
|
+
]);
|
|
360
|
+
const ctx = makeCtx();
|
|
361
|
+
const runtime = createSyncRuntime(ctx, {
|
|
362
|
+
providerFactory: hubDocProviderFactory({
|
|
363
|
+
'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
|
|
364
|
+
'vendor-b': { body: 'x\n', path: 'vendor/b.tsx' },
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
await runtime?.start();
|
|
368
|
+
await runtime?.stop();
|
|
369
|
+
|
|
370
|
+
const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
|
|
371
|
+
const learned = cfg.canvasGroups
|
|
372
|
+
.map((g: { path: string }) => g.path)
|
|
373
|
+
.filter((p: string) => p !== 'system' && p !== 'ui');
|
|
374
|
+
expect(learned.length).toBe(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('a design root with work in it is NOT a fresh link', async () => {
|
|
378
|
+
// `localCanvases.length === 0` is a fact about the SCAN, which walks only
|
|
379
|
+
// declared groups and applies the syncable + sandbox gates — so a project
|
|
380
|
+
// with real work scans to zero for several innocent reasons, and a hub must
|
|
381
|
+
// not get to author a config.json into it.
|
|
382
|
+
const ctx = makeCtx();
|
|
383
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
384
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'Existing.tsx'), 'x\n');
|
|
385
|
+
writeFileSync(
|
|
386
|
+
join(ctx.paths.designRoot, 'ui', 'Existing.meta.json'),
|
|
387
|
+
JSON.stringify({ syncable: false })
|
|
388
|
+
);
|
|
389
|
+
hubListing([{ name: 'ws/acme/main/screens-a', bytes: 10 }]);
|
|
390
|
+
|
|
391
|
+
const runtime = createSyncRuntime(ctx, {
|
|
392
|
+
providerFactory: hubDocProviderFactory({
|
|
393
|
+
'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
await runtime?.start();
|
|
397
|
+
await runtime?.stop();
|
|
398
|
+
|
|
399
|
+
expect(existsSync(join(ctx.paths.designRoot, 'config.json'))).toBe(false);
|
|
400
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens', 'a.tsx'))).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// on the hub and no local counter could ever show it.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { canvasSlugFromRel } from '../canvas-slug.ts';
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
describeRemoteDiff,
|
|
@@ -135,7 +136,11 @@ describe('pulling hub-only documents down', () => {
|
|
|
135
136
|
expect(slugFromDocName('ws/acme/main/ui-welcome')).toBe('ui-welcome');
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
test('lands hub-only canvases
|
|
139
|
+
test('lands hub-only canvases where the tree can SEE them', () => {
|
|
140
|
+
// Flat, because a slug cannot be un-flattened — but inside the canvas group
|
|
141
|
+
// the slug came from, not at the design root. The tree and `scanCanvases`
|
|
142
|
+
// enumerate `canvasGroups`, so the old design-root target produced a file
|
|
143
|
+
// nobody could see and that never synced onward.
|
|
139
144
|
const targets = pullTargets(
|
|
140
145
|
[
|
|
141
146
|
{ name: 'ui-welcome', bytes: 1 },
|
|
@@ -147,12 +152,59 @@ describe('pulling hub-only documents down', () => {
|
|
|
147
152
|
'/'
|
|
148
153
|
);
|
|
149
154
|
expect(targets.map((t) => t.bodyAbs)).toEqual([
|
|
150
|
-
'/p/.design/ui
|
|
151
|
-
'/p/.design/ui
|
|
155
|
+
'/p/.design/ui/welcome.tsx',
|
|
156
|
+
'/p/.design/ui/home.tsx',
|
|
152
157
|
]);
|
|
158
|
+
// …and each still slugs back to the document it came from. `ui/ui-welcome.tsx`
|
|
159
|
+
// would be visible AND a different document.
|
|
160
|
+
expect(canvasSlugFromRel('ui/welcome.tsx', '.design')).toBe('ui-welcome');
|
|
153
161
|
// The wire name is kept — the provider must open the document the HUB has,
|
|
154
162
|
// not a name re-derived from the local path.
|
|
155
163
|
expect(targets[1].docName).toBe('ws/acme/main/ui-home');
|
|
164
|
+
// No path arrived, so nothing was believed.
|
|
165
|
+
expect(targets.every((t) => t.fromPath === false)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('uses the path a document carries, once it is known', () => {
|
|
169
|
+
// The listing cannot supply this — it carries names and byte counts only.
|
|
170
|
+
// The runtime re-resolves per document after that document has synced;
|
|
171
|
+
// `pathFor` is that hop, made testable.
|
|
172
|
+
const targets = pullTargets(
|
|
173
|
+
[{ name: 'ws/acme/main/ui-2026-social-summer-camp', bytes: 1 }],
|
|
174
|
+
'/p/.design',
|
|
175
|
+
join,
|
|
176
|
+
resolve,
|
|
177
|
+
'/',
|
|
178
|
+
{
|
|
179
|
+
designRel: '.design',
|
|
180
|
+
canvasGroups: [{ path: 'system' }, { path: 'ui' }],
|
|
181
|
+
pathFor: () => 'ui/2026/social/summer-camp.tsx',
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
expect(targets[0].bodyAbs).toBe('/p/.design/ui/2026/social/summer-camp.tsx');
|
|
185
|
+
expect(targets[0].fromPath).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a refused path degrades to the fallback and says why', () => {
|
|
189
|
+
const refusals: string[] = [];
|
|
190
|
+
const targets = pullTargets(
|
|
191
|
+
[{ name: 'ui-welcome', bytes: 1 }],
|
|
192
|
+
'/p/.design',
|
|
193
|
+
join,
|
|
194
|
+
resolve,
|
|
195
|
+
'/',
|
|
196
|
+
{
|
|
197
|
+
designRel: '.design',
|
|
198
|
+
canvasGroups: [{ path: 'ui' }],
|
|
199
|
+
// A perfectly well-formed path that simply is not this document's —
|
|
200
|
+
// the case rule 7 exists for.
|
|
201
|
+
pathFor: () => 'ui/somebody-elses-canvas.tsx',
|
|
202
|
+
onRefused: (slug, reason) => refusals.push(`${slug}: ${reason}`),
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
expect(targets[0].bodyAbs).toBe('/p/.design/ui/welcome.tsx');
|
|
206
|
+
expect(targets[0].fromPath).toBe(false);
|
|
207
|
+
expect(refusals.length).toBe(1);
|
|
156
208
|
});
|
|
157
209
|
|
|
158
210
|
test('a document NAME can never write outside the design root', () => {
|
|
@@ -1171,6 +1171,124 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
|
|
|
1171
1171
|
}
|
|
1172
1172
|
});
|
|
1173
1173
|
|
|
1174
|
+
test('a successful re-probe clears the refusal, and the reconcile is a separate word', async () => {
|
|
1175
|
+
// A REGRESSION GUARD, not a falsifier — this already held. It is here
|
|
1176
|
+
// because the clear moved: it used to sit at the BOTTOM of the
|
|
1177
|
+
// post-handshake path, below a reconcile that can throw into a rejection
|
|
1178
|
+
// `settleWait` swallows, so a document that reconnected fine but failed to
|
|
1179
|
+
// settle on disk would have kept telling the user to go fix a credential
|
|
1180
|
+
// that was never the problem. The clear now runs on the handshake, and the
|
|
1181
|
+
// reconcile reports separately (`pending` vs `connected`) and loudly.
|
|
1182
|
+
const url = 'https://hub.example.com';
|
|
1183
|
+
writeHubsConfig(url, 'mau_test');
|
|
1184
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1185
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1186
|
+
|
|
1187
|
+
const timers = fakeTimerQueue();
|
|
1188
|
+
const origWarn = console.warn;
|
|
1189
|
+
const origLog = console.log;
|
|
1190
|
+
console.warn = () => {};
|
|
1191
|
+
console.log = () => {};
|
|
1192
|
+
|
|
1193
|
+
let round = 0;
|
|
1194
|
+
const factory = (args: { documentName: string; document?: Y.Doc }) => {
|
|
1195
|
+
const document = args.document ?? new Y.Doc();
|
|
1196
|
+
const first = round++ === 0;
|
|
1197
|
+
return {
|
|
1198
|
+
document,
|
|
1199
|
+
onAuthFailed(cb: (info: { reason: string }) => void) {
|
|
1200
|
+
if (first) queueMicrotask(() => cb({ reason: 'token not authorized' }));
|
|
1201
|
+
return () => {};
|
|
1202
|
+
},
|
|
1203
|
+
onceSynced: () => (first ? new Promise<void>(() => {}) : Promise.resolve()),
|
|
1204
|
+
destroy() {},
|
|
1205
|
+
};
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1210
|
+
providerFactory: factory,
|
|
1211
|
+
auth: {
|
|
1212
|
+
warnDebounceMs: 2_000,
|
|
1213
|
+
reprobeMs: 300_000,
|
|
1214
|
+
settleTimeoutMs: 15_000,
|
|
1215
|
+
setTimer: timers.setTimer,
|
|
1216
|
+
clearTimer: timers.clearTimer,
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
await runtime?.start();
|
|
1220
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1221
|
+
expect(runtime?.status().docs?.rejected).toBe(1);
|
|
1222
|
+
|
|
1223
|
+
timers.fire(300_000);
|
|
1224
|
+
await new Promise((res) => setTimeout(res, 40));
|
|
1225
|
+
|
|
1226
|
+
expect(runtime?.status().docs?.rejected).toBe(0);
|
|
1227
|
+
expect(runtime?.status().rejectedSlugs ?? []).toEqual([]);
|
|
1228
|
+
|
|
1229
|
+
await runtime?.stop();
|
|
1230
|
+
} finally {
|
|
1231
|
+
console.warn = origWarn;
|
|
1232
|
+
console.log = origLog;
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test('a new process never serves the previous one’s verdict, not even mid-boot', async () => {
|
|
1237
|
+
// `_sync.json` is a file and `/_sync-status` returns whatever is in it. The
|
|
1238
|
+
// first honest snapshot of a run is not written until every provider has
|
|
1239
|
+
// been built — after a scan AND a listing fetch that waits up to 6 seconds.
|
|
1240
|
+
// For that whole window the CLI and the browser banner were reading the
|
|
1241
|
+
// LAST run's counters as current. So the window is what this asserts: the
|
|
1242
|
+
// fetch is held open, and the file must ALREADY be clean.
|
|
1243
|
+
const url = 'https://hub.example.com';
|
|
1244
|
+
writeHubsConfig(url, 'mau_test');
|
|
1245
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1246
|
+
const statusFile = join(ctx.paths.designRoot, '_sync.json');
|
|
1247
|
+
writeFileSync(
|
|
1248
|
+
statusFile,
|
|
1249
|
+
JSON.stringify({
|
|
1250
|
+
url,
|
|
1251
|
+
canvases: 76,
|
|
1252
|
+
state: 'online',
|
|
1253
|
+
docs: { synced: 0, pending: 3, rejected: 73 },
|
|
1254
|
+
rejectedSlugs: ['ui-a', 'ui-b'],
|
|
1255
|
+
})
|
|
1256
|
+
);
|
|
1257
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1258
|
+
|
|
1259
|
+
const realFetch = globalThis.fetch;
|
|
1260
|
+
let releaseListing: () => void = () => {};
|
|
1261
|
+
const listingReached = new Promise<void>((res) => {
|
|
1262
|
+
globalThis.fetch = (async () => {
|
|
1263
|
+
res();
|
|
1264
|
+
await new Promise<void>((r) => {
|
|
1265
|
+
releaseListing = r;
|
|
1266
|
+
});
|
|
1267
|
+
return new Response(JSON.stringify({ documents: [] }), { status: 200 });
|
|
1268
|
+
}) as typeof fetch;
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
try {
|
|
1272
|
+
const { factory } = inMemoryProviderFactory();
|
|
1273
|
+
const runtime = createSyncRuntime(ctx, { providerFactory: factory });
|
|
1274
|
+
const booting = runtime?.start();
|
|
1275
|
+
await listingReached;
|
|
1276
|
+
|
|
1277
|
+
// Mid-boot, with the listing still in flight — exactly where the stale
|
|
1278
|
+
// file used to be authoritative.
|
|
1279
|
+
const midBoot = JSON.parse(readFileSync(statusFile, 'utf8'));
|
|
1280
|
+
expect(midBoot.docs.rejected).toBe(0);
|
|
1281
|
+
expect(midBoot.rejectedSlugs ?? []).toEqual([]);
|
|
1282
|
+
expect(midBoot.state).toBe('connecting');
|
|
1283
|
+
|
|
1284
|
+
releaseListing();
|
|
1285
|
+
await booting;
|
|
1286
|
+
await runtime?.stop();
|
|
1287
|
+
} finally {
|
|
1288
|
+
globalThis.fetch = realFetch;
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1174
1292
|
test('boot summary prints AFTER settle with honest counts (not the premature N/N)', async () => {
|
|
1175
1293
|
const url = 'https://hub.example.com';
|
|
1176
1294
|
writeHubsConfig(url, 'mau_test');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// timeline-media-cache.test.ts — bug fix: dropping a clip on the timeline
|
|
2
|
+
// used to always trim it to a flat 3s instead of its real duration
|
|
3
|
+
// (durationInFrames was `Math.round(fps * 3)` regardless of the probed
|
|
4
|
+
// media length). durationFramesForDrop is the pure decision the drop
|
|
5
|
+
// handler in app.jsx now delegates to.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { durationFramesForDrop } from '../client/panels/timeline-media-cache.js';
|
|
10
|
+
|
|
11
|
+
describe('durationFramesForDrop', () => {
|
|
12
|
+
test('uses the real probed duration, not the flat 3s default', () => {
|
|
13
|
+
// A 7.4s clip at 30fps must land at its own length (222 frames), not the
|
|
14
|
+
// fixed fps*3 = 90 frames the drop handler used to hardcode.
|
|
15
|
+
expect(durationFramesForDrop(30, 7.4)).toBe(Math.round(30 * 7.4));
|
|
16
|
+
expect(durationFramesForDrop(30, 7.4)).not.toBe(Math.round(30 * 3));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('rounds to the nearest frame', () => {
|
|
20
|
+
expect(durationFramesForDrop(24, 2.501)).toBe(60); // 24 * 2.501 = 60.024
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('falls back to 3s when no duration was probed (null/failed probe)', () => {
|
|
24
|
+
expect(durationFramesForDrop(30, null)).toBe(90);
|
|
25
|
+
expect(durationFramesForDrop(30, undefined)).toBe(90);
|
|
26
|
+
expect(durationFramesForDrop(30, NaN)).toBe(90);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('falls back to 3s for a non-positive probed duration', () => {
|
|
30
|
+
expect(durationFramesForDrop(30, 0)).toBe(90);
|
|
31
|
+
expect(durationFramesForDrop(30, -1)).toBe(90);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a caller can override the fallback (e.g. synthetic content)', () => {
|
|
35
|
+
expect(durationFramesForDrop(30, null, 5)).toBe(150);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('never returns less than 1 frame', () => {
|
|
39
|
+
expect(durationFramesForDrop(30, 0.001)).toBeGreaterThanOrEqual(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "sync-carries-the-path",
|
|
6
|
+
"version": "0.58.2",
|
|
7
|
+
"date": "2026-08-08",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Canvases arrive in the folder you made",
|
|
10
|
+
"summary": "A canvas you create in a nested folder now shows up on your other machine — and in the cloud — in that same folder, instead of quietly landing somewhere the file tree never listed. Connecting a project to an empty folder brings the whole thing down with its structure intact.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"id": "release-version-visible",
|
|
6
15
|
"version": "0.58.0",
|