@1agh/maude 0.53.2 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +1007 -1007
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +145 -12
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Cloud Phase 27 D2 — one writer at a time on the workspace checkout.
|
|
2
|
+
//
|
|
3
|
+
// The lock is the thing standing between "two processes over one working tree"
|
|
4
|
+
// and the preserved dissent's 3 a.m. event, so the properties asserted here are
|
|
5
|
+
// the ones that make it a lock rather than a suggestion: mutual exclusion,
|
|
6
|
+
// release on throw, and a stale holder that unwedges itself without a human.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import {
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
utimesSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { HEARTBEAT_MS, REPO_LOCK_FILE, STALE_LOCK_MS, withRepoLock } from '../git/repo-lock.ts';
|
|
22
|
+
|
|
23
|
+
function makeRepo({ git = true } = {}): string {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-repo-lock-'));
|
|
25
|
+
if (git) mkdirSync(join(root, '.git'), { recursive: true });
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lockFile = (root: string) => join(root, '.git', REPO_LOCK_FILE);
|
|
30
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
describe('repo lock', () => {
|
|
33
|
+
test('two holders never overlap — the loser waits rather than failing', async () => {
|
|
34
|
+
const root = makeRepo();
|
|
35
|
+
try {
|
|
36
|
+
const order: string[] = [];
|
|
37
|
+
let concurrent = 0;
|
|
38
|
+
let maxConcurrent = 0;
|
|
39
|
+
|
|
40
|
+
const contend = (name: string) =>
|
|
41
|
+
withRepoLock(root, name, async () => {
|
|
42
|
+
concurrent += 1;
|
|
43
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
44
|
+
order.push(`${name}:in`);
|
|
45
|
+
await sleep(30);
|
|
46
|
+
order.push(`${name}:out`);
|
|
47
|
+
concurrent -= 1;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await Promise.all([contend('hub:autocommit'), contend('studio:checkout')]);
|
|
51
|
+
|
|
52
|
+
// The point of the lock, stated twice: never two inside, and never an
|
|
53
|
+
// interleaved in/in/out/out — the second entry follows the first exit.
|
|
54
|
+
expect(maxConcurrent).toBe(1);
|
|
55
|
+
expect(order[1]).toBe(order[0].replace(':in', ':out'));
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the lock is released when the operation throws', async () => {
|
|
62
|
+
const root = makeRepo();
|
|
63
|
+
try {
|
|
64
|
+
await expect(
|
|
65
|
+
withRepoLock(root, 'studio:commit', async () => {
|
|
66
|
+
throw new Error('git exploded');
|
|
67
|
+
})
|
|
68
|
+
).rejects.toThrow('git exploded');
|
|
69
|
+
|
|
70
|
+
// Not merely "the file is gone" — the next caller gets in, which is the
|
|
71
|
+
// property that matters. A lock leaked by a failed commit would stop
|
|
72
|
+
// every autosave after it.
|
|
73
|
+
let ran = false;
|
|
74
|
+
await withRepoLock(root, 'hub:autocommit', async () => {
|
|
75
|
+
ran = true;
|
|
76
|
+
});
|
|
77
|
+
expect(ran).toBe(true);
|
|
78
|
+
expect(() => readFileSync(lockFile(root))).toThrow();
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a repo with no .git runs unlocked instead of creating one', async () => {
|
|
85
|
+
const root = makeRepo({ git: false });
|
|
86
|
+
try {
|
|
87
|
+
let ran = false;
|
|
88
|
+
await withRepoLock(root, 'hub:seed', async () => {
|
|
89
|
+
ran = true;
|
|
90
|
+
});
|
|
91
|
+
expect(ran).toBe(true);
|
|
92
|
+
// It must not have become a repo initializer on the way past.
|
|
93
|
+
expect(() => readFileSync(join(root, '.git', REPO_LOCK_FILE))).toThrow();
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(root, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a stale lock is stolen — a cell that crashed mid-commit unwedges itself', async () => {
|
|
100
|
+
const root = makeRepo();
|
|
101
|
+
try {
|
|
102
|
+
// A holder that still EXISTS as a process (this one) so the steal can only
|
|
103
|
+
// be attributed to the age, not to the liveness check.
|
|
104
|
+
writeFileSync(
|
|
105
|
+
lockFile(root),
|
|
106
|
+
JSON.stringify({ pid: process.pid, holder: 'hub:autocommit', at: 0, token: 'old' })
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
let ran = false;
|
|
110
|
+
const warned: string[] = [];
|
|
111
|
+
await withRepoLock(
|
|
112
|
+
root,
|
|
113
|
+
'studio:commit',
|
|
114
|
+
async () => {
|
|
115
|
+
ran = true;
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
waitMs: 1000,
|
|
119
|
+
// Age it past the window without waiting for it.
|
|
120
|
+
now: () => Date.now() + STALE_LOCK_MS + 5_000,
|
|
121
|
+
log: { warn: (m: string) => warned.push(m), log() {} },
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
expect(ran).toBe(true);
|
|
125
|
+
expect(warned.join(' ')).toContain('hub:autocommit');
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a lock whose process is gone is stolen without waiting out the window', async () => {
|
|
132
|
+
const root = makeRepo();
|
|
133
|
+
try {
|
|
134
|
+
// pid 1 is tini in a cell and always alive, so a pid that cannot exist is
|
|
135
|
+
// the honest fixture: 2^22 is above every default pid_max.
|
|
136
|
+
writeFileSync(
|
|
137
|
+
lockFile(root),
|
|
138
|
+
JSON.stringify({ pid: 4194304, holder: 'studio:checkout', at: Date.now(), token: 'dead' })
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let ran = false;
|
|
142
|
+
await withRepoLock(
|
|
143
|
+
root,
|
|
144
|
+
'hub:autocommit',
|
|
145
|
+
async () => {
|
|
146
|
+
ran = true;
|
|
147
|
+
},
|
|
148
|
+
{ waitMs: 500, log: { warn() {}, log() {} } }
|
|
149
|
+
);
|
|
150
|
+
expect(ran).toBe(true);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(root, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('giving up names the holder rather than hanging', async () => {
|
|
157
|
+
const root = makeRepo();
|
|
158
|
+
try {
|
|
159
|
+
writeFileSync(
|
|
160
|
+
lockFile(root),
|
|
161
|
+
JSON.stringify({ pid: process.pid, holder: 'studio:fold', at: Date.now(), token: 'live' })
|
|
162
|
+
);
|
|
163
|
+
await expect(
|
|
164
|
+
withRepoLock(root, 'hub:autocommit', async () => undefined, { waitMs: 120 })
|
|
165
|
+
).rejects.toThrow(/studio:fold/);
|
|
166
|
+
} finally {
|
|
167
|
+
rmSync(root, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('releasing never unlinks a lock that was stolen from us', async () => {
|
|
172
|
+
// The one way a lock can be worse than no lock: a slow holder finishes,
|
|
173
|
+
// unlinks, and pulls the lock out from under whoever legitimately took it
|
|
174
|
+
// after the steal.
|
|
175
|
+
const root = makeRepo();
|
|
176
|
+
try {
|
|
177
|
+
const finished = withRepoLock(root, 'hub:autocommit', async () => {
|
|
178
|
+
// Simulate the steal: somebody reaped us and took it.
|
|
179
|
+
writeFileSync(
|
|
180
|
+
lockFile(root),
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
pid: process.pid,
|
|
183
|
+
holder: 'studio:checkout',
|
|
184
|
+
at: Date.now(),
|
|
185
|
+
token: 'theirs',
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
await finished;
|
|
190
|
+
const after = JSON.parse(readFileSync(lockFile(root), 'utf8'));
|
|
191
|
+
expect(after.holder).toBe('studio:checkout');
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(root, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
test("a lost steal race does not unlink the winner's fresh lock", async () => {
|
|
197
|
+
// THE MUTUAL-EXCLUSION FAILURE THIS LOCK EXISTS TO PREVENT, REINTRODUCED BY
|
|
198
|
+
// ITS OWN RECOVERY PATH. Two waiters both see holder A as stale; the first
|
|
199
|
+
// unlinks and acquires; the second — still acting on its read of A —
|
|
200
|
+
// unlinks the FIRST's fresh lock and acquires too. Both then run their
|
|
201
|
+
// critical section over one git index.
|
|
202
|
+
//
|
|
203
|
+
// The fixture is aged through its MTIME rather than through a fake clock,
|
|
204
|
+
// and that distinction is the test: a fake `now` makes every lock look
|
|
205
|
+
// ancient, including the winner's brand-new one, which is not a state the
|
|
206
|
+
// real world can produce and would assert something nobody needs.
|
|
207
|
+
const root = makeRepo();
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(
|
|
210
|
+
lockFile(root),
|
|
211
|
+
JSON.stringify({ pid: process.pid, holder: 'hub:autocommit', at: 0, token: 'stale' })
|
|
212
|
+
);
|
|
213
|
+
const long_ago = new Date(Date.now() - STALE_LOCK_MS * 4);
|
|
214
|
+
utimesSync(lockFile(root), long_ago, long_ago);
|
|
215
|
+
|
|
216
|
+
let concurrent = 0;
|
|
217
|
+
let maxConcurrent = 0;
|
|
218
|
+
const contend = (name: string) =>
|
|
219
|
+
withRepoLock(
|
|
220
|
+
root,
|
|
221
|
+
name,
|
|
222
|
+
async () => {
|
|
223
|
+
concurrent += 1;
|
|
224
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
225
|
+
await sleep(60);
|
|
226
|
+
concurrent -= 1;
|
|
227
|
+
},
|
|
228
|
+
{ waitMs: 5000, log: { warn() {}, log() {} } }
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await Promise.all([contend('one'), contend('two'), contend('three')]);
|
|
232
|
+
expect(maxConcurrent).toBe(1);
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(root, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}, 20_000);
|
|
237
|
+
|
|
238
|
+
test('a long-but-live holder is not stolen from — the heartbeat says it is alive', async () => {
|
|
239
|
+
// `pull`, `fold` and `resolve` came under this lock in D2, and they do
|
|
240
|
+
// NETWORK I/O. Without a heartbeat the staleness test cannot tell "held for
|
|
241
|
+
// 30 s" from "the holder died 30 s ago", so the next waiter steals the lock
|
|
242
|
+
// out from under a running merge.
|
|
243
|
+
const root = makeRepo();
|
|
244
|
+
try {
|
|
245
|
+
const order: string[] = [];
|
|
246
|
+
const slow = withRepoLock(root, 'studio:pull', async () => {
|
|
247
|
+
order.push('slow:in');
|
|
248
|
+
await sleep(HEARTBEAT_MS + 400);
|
|
249
|
+
order.push('slow:out');
|
|
250
|
+
});
|
|
251
|
+
await sleep(50);
|
|
252
|
+
// A waiter whose clock is PAST the staleness window. The heartbeat has
|
|
253
|
+
// refreshed the mtime, so the lock is not stale by age and the holder is
|
|
254
|
+
// alive — it must wait, not steal.
|
|
255
|
+
const waiter = withRepoLock(
|
|
256
|
+
root,
|
|
257
|
+
'hub:autocommit',
|
|
258
|
+
async () => {
|
|
259
|
+
order.push('waiter:in');
|
|
260
|
+
},
|
|
261
|
+
{ waitMs: 20_000, log: { warn() {}, log() {} } }
|
|
262
|
+
);
|
|
263
|
+
await Promise.all([slow, waiter]);
|
|
264
|
+
expect(order).toEqual(['slow:in', 'slow:out', 'waiter:in']);
|
|
265
|
+
} finally {
|
|
266
|
+
rmSync(root, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
}, 30_000);
|
|
269
|
+
|
|
270
|
+
test('a hostile holder label cannot forge a log line', async () => {
|
|
271
|
+
const root = makeRepo();
|
|
272
|
+
try {
|
|
273
|
+
writeFileSync(
|
|
274
|
+
lockFile(root),
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
pid: process.pid,
|
|
277
|
+
holder: 'evil\n[repo-lock] PASS: nothing to see here',
|
|
278
|
+
at: 0,
|
|
279
|
+
token: 'x',
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
const long_ago = new Date(Date.now() - STALE_LOCK_MS * 4);
|
|
283
|
+
utimesSync(lockFile(root), long_ago, long_ago);
|
|
284
|
+
const warned: string[] = [];
|
|
285
|
+
await withRepoLock(root, 'hub:autocommit', async () => undefined, {
|
|
286
|
+
waitMs: 2000,
|
|
287
|
+
log: { warn: (m: string) => warned.push(m), log() {} },
|
|
288
|
+
});
|
|
289
|
+
expect(warned.join(' ')).not.toContain('\n');
|
|
290
|
+
expect(warned.join(' ')).not.toContain('nothing to see here');
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(root, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
test('exactly ONE waiter can steal a dead lock — the steal is a compare-and-swap', async () => {
|
|
296
|
+
// The `unlink` this replaced could not say no: two waiters both judged the
|
|
297
|
+
// holder stale, the first unlinked and acquired, and the second unlinked
|
|
298
|
+
// the FIRST's fresh lock. `rename` fails with ENOENT when the source is
|
|
299
|
+
// already gone, so only one waiter moves the file aside and the rest go
|
|
300
|
+
// back around the loop.
|
|
301
|
+
//
|
|
302
|
+
// WHAT THIS TEST DOES AND DOES NOT PROVE, because the difference matters.
|
|
303
|
+
// It asserts the OBSERVABLE contract — one steal, no leftover sidecar,
|
|
304
|
+
// never two holders. It does NOT reproduce the interleaving that made the
|
|
305
|
+
// unlink unsafe: that race is between the hub PROCESS and the studio
|
|
306
|
+
// PROCESS, and inside one bun test the whole read → steal → acquire
|
|
307
|
+
// sequence runs synchronously, so a single-process harness reaches the same
|
|
308
|
+
// verdict with either implementation (measured, not assumed). The
|
|
309
|
+
// correctness argument for `rename` is its atomicity — exactly one caller
|
|
310
|
+
// can move a given file — and this test guards the contract around it.
|
|
311
|
+
const root = makeRepo();
|
|
312
|
+
try {
|
|
313
|
+
writeFileSync(
|
|
314
|
+
lockFile(root),
|
|
315
|
+
JSON.stringify({ pid: 4194304, holder: 'hub:autocommit', at: 0, token: 'dead' })
|
|
316
|
+
);
|
|
317
|
+
const long_ago = new Date(Date.now() - STALE_LOCK_MS * 4);
|
|
318
|
+
utimesSync(lockFile(root), long_ago, long_ago);
|
|
319
|
+
|
|
320
|
+
const steals: string[] = [];
|
|
321
|
+
let concurrent = 0;
|
|
322
|
+
let maxConcurrent = 0;
|
|
323
|
+
const contend = (name: string) =>
|
|
324
|
+
withRepoLock(
|
|
325
|
+
root,
|
|
326
|
+
name,
|
|
327
|
+
async () => {
|
|
328
|
+
concurrent += 1;
|
|
329
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
330
|
+
await sleep(40);
|
|
331
|
+
concurrent -= 1;
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
waitMs: 5000,
|
|
335
|
+
log: { warn: (m: string) => steals.push(m), log() {} },
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
await Promise.all([contend('a'), contend('b'), contend('c'), contend('d')]);
|
|
340
|
+
|
|
341
|
+
expect(maxConcurrent).toBe(1);
|
|
342
|
+
// One steal, not four: the other three found the file already gone.
|
|
343
|
+
expect(steals.filter((m) => m.includes('stole a')).length).toBe(1);
|
|
344
|
+
// And no sidecar left behind.
|
|
345
|
+
expect(readdirSync(join(root, '.git')).filter((f) => f.includes('.stale-'))).toEqual([]);
|
|
346
|
+
} finally {
|
|
347
|
+
rmSync(root, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
}, 20_000);
|
|
350
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
|
+
import { rootIdentity } from '../http.ts';
|
|
6
7
|
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
7
8
|
|
|
8
9
|
describe('server lifecycle', () => {
|
|
@@ -13,9 +14,18 @@ describe('server lifecycle', () => {
|
|
|
13
14
|
try {
|
|
14
15
|
const h = await fetch(`http://localhost:${port}/_health`);
|
|
15
16
|
expect(h.status).toBe(200);
|
|
16
|
-
const body = (await h.json()) as { ok: boolean; app: string };
|
|
17
|
+
const body = (await h.json()) as { ok: boolean; app: string; rootId: string };
|
|
17
18
|
expect(body.ok).toBe(true);
|
|
18
19
|
expect(body.app).toBe('design');
|
|
20
|
+
// Cloud Phase 27 D5 — the supervisor asks this route WHICH TREE it is
|
|
21
|
+
// serving, and kills a child that answers about another one. That check
|
|
22
|
+
// is only worth anything if both ends compute the identity the same way,
|
|
23
|
+
// which is what this asserts: the hash the server reports for the root it
|
|
24
|
+
// resolved is the hash the supervisor would compute for the root it
|
|
25
|
+
// handed over. And it is a hash, not the path — this route is on the
|
|
26
|
+
// untrusted canvas origin's allowlist.
|
|
27
|
+
expect(body.rootId).toBe(rootIdentity(root));
|
|
28
|
+
expect(body.rootId).not.toContain('/');
|
|
19
29
|
|
|
20
30
|
const info = await Bun.file(join(designRoot, '_server.json')).json();
|
|
21
31
|
expect(info.port).toBe(port);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Cloud Phase 27 D3, end to end: two members of one cell keep their own place.
|
|
2
|
+
//
|
|
3
|
+
// The unit test next door proves the path helpers; this proves the wiring — a
|
|
4
|
+
// real server in workspace mode, two vouched sessions, and the two files the
|
|
5
|
+
// plan names by hand: `_active.json` (selection, open tabs) and
|
|
6
|
+
// `_canvas-state/<slug>.view.json` (the camera).
|
|
7
|
+
//
|
|
8
|
+
// The last test is the one that must never go red for the desktop: with no
|
|
9
|
+
// session header the server writes exactly the paths it always wrote.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { isMaudeRuntimeState } from '../git/service.ts';
|
|
16
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
17
|
+
|
|
18
|
+
/** The proxy's vouched headers for one member (studio-proxy.mjs injects these). */
|
|
19
|
+
function asMember(session: string, role = 'owner') {
|
|
20
|
+
return {
|
|
21
|
+
'x-maude-session': session,
|
|
22
|
+
'x-maude-role': role,
|
|
23
|
+
'x-maude-readonly': '0',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CANVAS = '.design/ui/fixture.html';
|
|
28
|
+
|
|
29
|
+
describe('two members, one cell', () => {
|
|
30
|
+
test('the camera is per member, and the desktop path is untouched', async () => {
|
|
31
|
+
const { root, designRoot } = makeSandbox();
|
|
32
|
+
const port = nextPort();
|
|
33
|
+
const proc = await bootServer(root, port, {
|
|
34
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
35
|
+
// A dev checkout resolves Playwright, a legitimate devDependency that
|
|
36
|
+
// would otherwise fail the module half of the containment assert before
|
|
37
|
+
// workspace mode could be tested at all.
|
|
38
|
+
MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
const base = `http://localhost:${port}`;
|
|
42
|
+
const patch = (session: string | null, zoom: number) =>
|
|
43
|
+
fetch(`${base}/_api/canvas-meta`, {
|
|
44
|
+
method: 'PATCH',
|
|
45
|
+
headers: {
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
...(session ? asMember(session) : {}),
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
file: CANVAS,
|
|
51
|
+
patch: { viewport: { x: zoom * 10, y: 0, zoom } },
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const alice = await patch('aaaaaaaaaaaaaaaa', 2);
|
|
56
|
+
const bob = await patch('bbbbbbbbbbbbbbbb', 3);
|
|
57
|
+
expect(alice.status).toBe(200);
|
|
58
|
+
expect(bob.status).toBe(200);
|
|
59
|
+
|
|
60
|
+
// Each member's camera is its own FILE — the whole point.
|
|
61
|
+
const aliceView = join(
|
|
62
|
+
designRoot,
|
|
63
|
+
'_canvas-state',
|
|
64
|
+
'aaaaaaaaaaaaaaaa',
|
|
65
|
+
'ui-fixture.view.json'
|
|
66
|
+
);
|
|
67
|
+
const bobView = join(designRoot, '_canvas-state', 'bbbbbbbbbbbbbbbb', 'ui-fixture.view.json');
|
|
68
|
+
expect(existsSync(aliceView)).toBe(true);
|
|
69
|
+
expect(existsSync(bobView)).toBe(true);
|
|
70
|
+
expect((await Bun.file(aliceView).json()).viewport.zoom).toBe(2);
|
|
71
|
+
expect((await Bun.file(bobView).json()).viewport.zoom).toBe(3);
|
|
72
|
+
|
|
73
|
+
// And each reads back their OWN camera, not the last writer's.
|
|
74
|
+
const read = async (session: string) => {
|
|
75
|
+
const r = await fetch(`${base}/_api/canvas-meta?file=${encodeURIComponent(CANVAS)}`, {
|
|
76
|
+
headers: asMember(session),
|
|
77
|
+
});
|
|
78
|
+
return (await r.json()) as { viewport?: { zoom?: number } };
|
|
79
|
+
};
|
|
80
|
+
expect((await read('aaaaaaaaaaaaaaaa')).viewport?.zoom).toBe(2);
|
|
81
|
+
expect((await read('bbbbbbbbbbbbbbbb')).viewport?.zoom).toBe(3);
|
|
82
|
+
} finally {
|
|
83
|
+
await killProc(proc);
|
|
84
|
+
}
|
|
85
|
+
}, 30_000);
|
|
86
|
+
|
|
87
|
+
test('with no session header the camera lands where it always did', async () => {
|
|
88
|
+
// The desktop regression gate. A `_canvas-state/<slug>.view.json` at the
|
|
89
|
+
// top level, no subdirectory, byte-for-byte the pre-D3 layout.
|
|
90
|
+
const { root, designRoot } = makeSandbox();
|
|
91
|
+
const port = nextPort();
|
|
92
|
+
const proc = await bootServer(root, port);
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
95
|
+
method: 'PATCH',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ file: CANVAS, patch: { viewport: { x: 1, y: 2, zoom: 1.5 } } }),
|
|
98
|
+
});
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
expect(existsSync(join(designRoot, '_canvas-state', 'ui-fixture.view.json'))).toBe(true);
|
|
101
|
+
} finally {
|
|
102
|
+
await killProc(proc);
|
|
103
|
+
}
|
|
104
|
+
}, 30_000);
|
|
105
|
+
|
|
106
|
+
test('a forged session key cannot escape the canvas-state directory', async () => {
|
|
107
|
+
// The key becomes a path segment, so it is a traversal surface. The proxy
|
|
108
|
+
// strips inbound `x-maude-*` before injecting its own, which is the real
|
|
109
|
+
// defence; this is the second one.
|
|
110
|
+
const { root, designRoot } = makeSandbox();
|
|
111
|
+
const port = nextPort();
|
|
112
|
+
const proc = await bootServer(root, port, {
|
|
113
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
114
|
+
// A dev checkout resolves Playwright, a legitimate devDependency that
|
|
115
|
+
// would otherwise fail the module half of the containment assert before
|
|
116
|
+
// workspace mode could be tested at all.
|
|
117
|
+
MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
121
|
+
method: 'PATCH',
|
|
122
|
+
headers: {
|
|
123
|
+
'content-type': 'application/json',
|
|
124
|
+
'x-maude-session': '../../../../tmp/pwned',
|
|
125
|
+
'x-maude-role': 'owner',
|
|
126
|
+
'x-maude-readonly': '0',
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({ file: CANVAS, patch: { viewport: { x: 0, y: 0, zoom: 1 } } }),
|
|
129
|
+
});
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
// Refused, not sanitized — so it lands on the shared singleton, exactly
|
|
132
|
+
// where a request with no session at all would.
|
|
133
|
+
expect(existsSync(join(designRoot, '_canvas-state', 'ui-fixture.view.json'))).toBe(true);
|
|
134
|
+
expect(existsSync('/tmp/pwned')).toBe(false);
|
|
135
|
+
} finally {
|
|
136
|
+
await killProc(proc);
|
|
137
|
+
}
|
|
138
|
+
}, 30_000);
|
|
139
|
+
|
|
140
|
+
test('`_active.json` is per member too', async () => {
|
|
141
|
+
// Selection + open tabs travel over the inspector WebSocket, so this drives
|
|
142
|
+
// the socket the way the client does — including the handshake, which is
|
|
143
|
+
// where the session is stamped (a role, and now an identity, is per session
|
|
144
|
+
// and a handshake is the one moment it is unambiguous).
|
|
145
|
+
const { root, designRoot } = makeSandbox();
|
|
146
|
+
const port = nextPort();
|
|
147
|
+
const proc = await bootServer(root, port, {
|
|
148
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
149
|
+
// A dev checkout resolves Playwright, a legitimate devDependency that
|
|
150
|
+
// would otherwise fail the module half of the containment assert before
|
|
151
|
+
// workspace mode could be tested at all.
|
|
152
|
+
MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const openAs = (session: string, file: string) =>
|
|
156
|
+
new Promise<void>((resolve, reject) => {
|
|
157
|
+
const ws = new WebSocket(`ws://localhost:${port}/_ws`, {
|
|
158
|
+
headers: asMember(session),
|
|
159
|
+
} as unknown as string[]);
|
|
160
|
+
const timer = setTimeout(() => reject(new Error('socket timeout')), 8000);
|
|
161
|
+
ws.onopen = () => {
|
|
162
|
+
ws.send(JSON.stringify({ type: 'active', file }));
|
|
163
|
+
ws.send(JSON.stringify({ type: 'tabs', tabs: [file] }));
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
ws.close();
|
|
167
|
+
resolve();
|
|
168
|
+
}, 400);
|
|
169
|
+
};
|
|
170
|
+
ws.onerror = (e) => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
reject(e);
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await openAs('aaaaaaaaaaaaaaaa', '.design/ui/alice.tsx');
|
|
177
|
+
await openAs('bbbbbbbbbbbbbbbb', '.design/ui/bob.tsx');
|
|
178
|
+
await Bun.sleep(300);
|
|
179
|
+
|
|
180
|
+
const aliceState = await Bun.file(join(designRoot, '_active.aaaaaaaaaaaaaaaa.json')).json();
|
|
181
|
+
const bobState = await Bun.file(join(designRoot, '_active.bbbbbbbbbbbbbbbb.json')).json();
|
|
182
|
+
expect(aliceState.active).toBe('.design/ui/alice.tsx');
|
|
183
|
+
expect(bobState.active).toBe('.design/ui/bob.tsx');
|
|
184
|
+
// Neither clobbered the other, which before D3 is exactly what happened.
|
|
185
|
+
expect(aliceState.open_tabs).toEqual(['.design/ui/alice.tsx']);
|
|
186
|
+
expect(bobState.open_tabs).toEqual(['.design/ui/bob.tsx']);
|
|
187
|
+
} finally {
|
|
188
|
+
await killProc(proc);
|
|
189
|
+
}
|
|
190
|
+
}, 30_000);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('per-member runtime state is runtime state to git', () => {
|
|
194
|
+
test('all three matchers agree that `_active.<session>.json` is not a commit', () => {
|
|
195
|
+
// The comment in session-scope.ts CLAIMED a sibling kept every matcher
|
|
196
|
+
// correct. It did not: `.gitignore`, the CLI's ignore block and the
|
|
197
|
+
// studio's own `isMaudeRuntimeState` all matched `_active.json` by NAME, so
|
|
198
|
+
// each member's file showed as untracked to EVERYONE, "Save all" staged it,
|
|
199
|
+
// and a push published one person's open tabs, active canvas and selection
|
|
200
|
+
// into the tenant's remote.
|
|
201
|
+
const key = 'a1b2c3d4e5f60718';
|
|
202
|
+
|
|
203
|
+
// 1. the studio's own matcher — what the Changes panel filters by
|
|
204
|
+
expect(isMaudeRuntimeState(`.design/_active.${key}.json`)).toBe(true);
|
|
205
|
+
expect(isMaudeRuntimeState('.design/_active.json')).toBe(true);
|
|
206
|
+
expect(isMaudeRuntimeState(`.design/_canvas-state/${key}/ui-home.view.json`)).toBe(true);
|
|
207
|
+
// …and it must not have widened into ordinary files on the way.
|
|
208
|
+
expect(isMaudeRuntimeState('.design/ui/Home.tsx')).toBe(false);
|
|
209
|
+
expect(isMaudeRuntimeState('.design/ui/Home.meta.json')).toBe(false);
|
|
210
|
+
expect(isMaudeRuntimeState('.design/_activeXjson')).toBe(false);
|
|
211
|
+
|
|
212
|
+
// 2. this repo's own .gitignore
|
|
213
|
+
const ignored = (rel: string) =>
|
|
214
|
+
Bun.spawnSync(['git', 'check-ignore', '-q', rel], {
|
|
215
|
+
cwd: join(import.meta.dir, '..', '..', '..'),
|
|
216
|
+
}).exitCode === 0;
|
|
217
|
+
expect(ignored(`.design/_active.${key}.json`)).toBe(true);
|
|
218
|
+
|
|
219
|
+
// 3. the block the CLI writes into a downstream project
|
|
220
|
+
const block = readFileSync(
|
|
221
|
+
join(import.meta.dir, '..', '..', '..', 'cli', 'lib', 'gitignore-block.mjs'),
|
|
222
|
+
'utf8'
|
|
223
|
+
);
|
|
224
|
+
expect(block).toContain('_active.*.json');
|
|
225
|
+
});
|
|
226
|
+
});
|