@1agh/maude 0.30.0 → 0.31.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/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// canvas-list-watch — external-canvas list watcher. Emits `canvas-list-update`
|
|
2
|
+
// when a canvas file appears/disappears on disk from OUTSIDE the dev-server
|
|
3
|
+
// (the ACP agent's `/design:new`, agent Write, git checkout), so the browser
|
|
4
|
+
// file tree refreshes without a reload.
|
|
5
|
+
// RCA: .ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CANVAS_LIST_DEBOUNCE_MS,
|
|
14
|
+
createCanvasListWatch,
|
|
15
|
+
isCanvasCandidate,
|
|
16
|
+
} from '../canvas-list-watch.ts';
|
|
17
|
+
import { type Context, createBus } from '../context.ts';
|
|
18
|
+
import { createFsWatch } from '../fs-watch.ts';
|
|
19
|
+
|
|
20
|
+
const GROUPS = [
|
|
21
|
+
{ label: 'System', path: 'system' },
|
|
22
|
+
{ label: 'UI', path: 'ui' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function sandbox(): { root: string; designRoot: string } {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), 'clw-test-'));
|
|
27
|
+
const designRoot = join(root, '.design');
|
|
28
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
29
|
+
return { root, designRoot };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mkCtx(designRoot: string): Context {
|
|
33
|
+
return {
|
|
34
|
+
cfg: { canvasGroups: GROUPS } as Context['cfg'],
|
|
35
|
+
projectLabel: '',
|
|
36
|
+
paths: { designRoot, designRel: '.design' } as Context['paths'],
|
|
37
|
+
bus: createBus(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TSX = 'export default function C(){return null}';
|
|
42
|
+
|
|
43
|
+
interface Update {
|
|
44
|
+
action?: string;
|
|
45
|
+
rel?: string;
|
|
46
|
+
slug?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collect(ctx: Context): Update[] {
|
|
50
|
+
const got: Update[] = [];
|
|
51
|
+
ctx.bus.on('canvas-list-update', (p) => got.push(p as Update));
|
|
52
|
+
return got;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('canvas-list-watch / isCanvasCandidate (pure gate)', () => {
|
|
56
|
+
const groups = ['system', 'ui'];
|
|
57
|
+
test('accepts openable canvases under a configured group', () => {
|
|
58
|
+
expect(isCanvasCandidate('ui/Pricing.tsx', groups)).toBe(true);
|
|
59
|
+
expect(isCanvasCandidate('ui/project/Pricing.tsx', groups)).toBe(true);
|
|
60
|
+
expect(isCanvasCandidate('ui/Smoke.html', groups)).toBe(true);
|
|
61
|
+
// DS preview specimens DO show in the tree, so they must trigger a refresh.
|
|
62
|
+
expect(isCanvasCandidate('system/project/preview/colors.tsx', groups)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('rejects non-canvas extensions (sidecars, tokens, docs)', () => {
|
|
66
|
+
expect(isCanvasCandidate('ui/Pricing.meta.json', groups)).toBe(false);
|
|
67
|
+
expect(isCanvasCandidate('ui/Pricing.css', groups)).toBe(false);
|
|
68
|
+
expect(isCanvasCandidate('system/project/README.md', groups)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('rejects runtime-state (`_`-prefixed segment) + SKIP_DIRS', () => {
|
|
72
|
+
expect(isCanvasCandidate('_history/ui-x/00-screen.tsx', groups)).toBe(false);
|
|
73
|
+
expect(isCanvasCandidate('ui/_draft.tsx', groups)).toBe(false);
|
|
74
|
+
expect(isCanvasCandidate('_active.json', groups)).toBe(false);
|
|
75
|
+
expect(isCanvasCandidate('node_modules/pkg/index.tsx', groups)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('rejects canvases outside any configured group', () => {
|
|
79
|
+
expect(isCanvasCandidate('docs/Thing.tsx', groups)).toBe(false);
|
|
80
|
+
expect(isCanvasCandidate('Thing.tsx', groups)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('non-string / empty input is rejected, not thrown', () => {
|
|
84
|
+
expect(isCanvasCandidate('', groups)).toBe(false);
|
|
85
|
+
expect(isCanvasCandidate(undefined as unknown as string, groups)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('canvas-list-watch / diff + emit', () => {
|
|
90
|
+
test('seeding existing canvases emits nothing', async () => {
|
|
91
|
+
const { root, designRoot } = sandbox();
|
|
92
|
+
writeFileSync(join(designRoot, 'ui', 'Existing.tsx'), TSX);
|
|
93
|
+
const ctx = mkCtx(designRoot);
|
|
94
|
+
const got = collect(ctx);
|
|
95
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
96
|
+
await w.ready;
|
|
97
|
+
try {
|
|
98
|
+
expect(got).toHaveLength(0);
|
|
99
|
+
expect(w.known.has('.design/ui/Existing.tsx')).toBe(true);
|
|
100
|
+
} finally {
|
|
101
|
+
w.stop();
|
|
102
|
+
rmSync(root, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a canvas written straight to disk emits action:added (the bug)', async () => {
|
|
107
|
+
const { root, designRoot } = sandbox();
|
|
108
|
+
const ctx = mkCtx(designRoot);
|
|
109
|
+
const got = collect(ctx);
|
|
110
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
111
|
+
await w.ready;
|
|
112
|
+
try {
|
|
113
|
+
// Simulate `/design:new` writing the canvas (and its sidecar) directly —
|
|
114
|
+
// NO API call, so api.ts never emits.
|
|
115
|
+
writeFileSync(join(designRoot, 'ui', 'Pricing.tsx'), TSX);
|
|
116
|
+
writeFileSync(join(designRoot, 'ui', 'Pricing.meta.json'), '{}');
|
|
117
|
+
await w.refresh();
|
|
118
|
+
expect(got).toHaveLength(1);
|
|
119
|
+
expect(got[0]?.action).toBe('added');
|
|
120
|
+
expect(got[0]?.slug).toBe('ui-pricing');
|
|
121
|
+
} finally {
|
|
122
|
+
w.stop();
|
|
123
|
+
rmSync(root, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('deleting a canvas on disk emits action:removed', async () => {
|
|
128
|
+
const { root, designRoot } = sandbox();
|
|
129
|
+
const ctx = mkCtx(designRoot);
|
|
130
|
+
const got = collect(ctx);
|
|
131
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
132
|
+
await w.ready;
|
|
133
|
+
try {
|
|
134
|
+
const abs = join(designRoot, 'ui', 'Gone.tsx');
|
|
135
|
+
writeFileSync(abs, TSX);
|
|
136
|
+
await w.refresh();
|
|
137
|
+
got.length = 0; // drop the 'added'
|
|
138
|
+
rmSync(abs);
|
|
139
|
+
await w.refresh();
|
|
140
|
+
expect(got).toHaveLength(1);
|
|
141
|
+
expect(got[0]?.action).toBe('removed');
|
|
142
|
+
expect(got[0]?.slug).toBe('ui-gone');
|
|
143
|
+
} finally {
|
|
144
|
+
w.stop();
|
|
145
|
+
rmSync(root, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('editing an existing canvas body emits nothing (no tree thrash)', async () => {
|
|
150
|
+
const { root, designRoot } = sandbox();
|
|
151
|
+
const abs = join(designRoot, 'ui', 'Edited.tsx');
|
|
152
|
+
writeFileSync(abs, TSX);
|
|
153
|
+
const ctx = mkCtx(designRoot);
|
|
154
|
+
const got = collect(ctx);
|
|
155
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
156
|
+
await w.ready;
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(abs, `${TSX}\n// edited`);
|
|
159
|
+
writeFileSync(join(designRoot, 'ui', 'Edited.meta.json'), '{"x":1}');
|
|
160
|
+
await w.refresh();
|
|
161
|
+
expect(got).toHaveLength(0);
|
|
162
|
+
} finally {
|
|
163
|
+
w.stop();
|
|
164
|
+
rmSync(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('runtime-state + sidecar writes never emit', async () => {
|
|
169
|
+
const { root, designRoot } = sandbox();
|
|
170
|
+
const ctx = mkCtx(designRoot);
|
|
171
|
+
const got = collect(ctx);
|
|
172
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
173
|
+
await w.ready;
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(join(designRoot, '_active.json'), '{}');
|
|
176
|
+
mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
|
|
177
|
+
writeFileSync(join(designRoot, '_canvas-state', 'ui-x.view.json'), '{}');
|
|
178
|
+
writeFileSync(join(designRoot, 'config.json'), '{}');
|
|
179
|
+
await w.refresh();
|
|
180
|
+
expect(got).toHaveLength(0);
|
|
181
|
+
} finally {
|
|
182
|
+
w.stop();
|
|
183
|
+
rmSync(root, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('canvas-list-watch / live fs:any path', () => {
|
|
189
|
+
test('a candidate fs:any event schedules a debounced refresh that emits', async () => {
|
|
190
|
+
const { root, designRoot } = sandbox();
|
|
191
|
+
const ctx = mkCtx(designRoot);
|
|
192
|
+
const got = collect(ctx);
|
|
193
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 20 });
|
|
194
|
+
await w.ready;
|
|
195
|
+
try {
|
|
196
|
+
writeFileSync(join(designRoot, 'ui', 'Live.tsx'), TSX);
|
|
197
|
+
// The fs-watch would emit this; assert the subscriber wires through.
|
|
198
|
+
ctx.bus.emit('fs:any', 'ui/Live.tsx');
|
|
199
|
+
await new Promise((r) => setTimeout(r, 20 + 80));
|
|
200
|
+
await w.refresh(); // flush the chain
|
|
201
|
+
const added = got.find((m) => m.action === 'added' && m.slug === 'ui-live');
|
|
202
|
+
expect(added).toBeTruthy();
|
|
203
|
+
// Exactly one 'added' — the timer + the explicit flush must not double-emit.
|
|
204
|
+
expect(got.filter((m) => m.action === 'added' && m.slug === 'ui-live')).toHaveLength(1);
|
|
205
|
+
} finally {
|
|
206
|
+
w.stop();
|
|
207
|
+
rmSync(root, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('a non-candidate fs:any event does not schedule a refresh', async () => {
|
|
212
|
+
const { root, designRoot } = sandbox();
|
|
213
|
+
const ctx = mkCtx(designRoot);
|
|
214
|
+
const got = collect(ctx);
|
|
215
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 20 });
|
|
216
|
+
await w.ready;
|
|
217
|
+
try {
|
|
218
|
+
// Create a canvas but only announce a NON-candidate path. The gate must
|
|
219
|
+
// skip the recompute, so the new canvas stays unseen until a real event.
|
|
220
|
+
writeFileSync(join(designRoot, 'ui', 'Sneaky.tsx'), TSX);
|
|
221
|
+
ctx.bus.emit('fs:any', 'ui/Sneaky.meta.json');
|
|
222
|
+
ctx.bus.emit('fs:any', '_active.json');
|
|
223
|
+
await new Promise((r) => setTimeout(r, 20 + 80));
|
|
224
|
+
expect(got).toHaveLength(0);
|
|
225
|
+
} finally {
|
|
226
|
+
w.stop();
|
|
227
|
+
rmSync(root, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('stop() unsubscribes — later events are ignored', async () => {
|
|
232
|
+
const { root, designRoot } = sandbox();
|
|
233
|
+
const ctx = mkCtx(designRoot);
|
|
234
|
+
const got = collect(ctx);
|
|
235
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
236
|
+
await w.ready;
|
|
237
|
+
w.stop();
|
|
238
|
+
try {
|
|
239
|
+
writeFileSync(join(designRoot, 'ui', 'After.tsx'), TSX);
|
|
240
|
+
ctx.bus.emit('fs:any', 'ui/After.tsx');
|
|
241
|
+
await new Promise((r) => setTimeout(r, CANVAS_LIST_DEBOUNCE_MS + 40));
|
|
242
|
+
expect(got).toHaveLength(0);
|
|
243
|
+
} finally {
|
|
244
|
+
rmSync(root, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('canvas-list-watch / serialization + multi-remove', () => {
|
|
250
|
+
test('overlapping refresh() calls serialize — a create between them emits exactly once', async () => {
|
|
251
|
+
const { root, designRoot } = sandbox();
|
|
252
|
+
const ctx = mkCtx(designRoot);
|
|
253
|
+
const got = collect(ctx);
|
|
254
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
255
|
+
await w.ready;
|
|
256
|
+
try {
|
|
257
|
+
writeFileSync(join(designRoot, 'ui', 'Pair.tsx'), TSX);
|
|
258
|
+
// Fire two refreshes WITHOUT awaiting the first. The promise-chain must
|
|
259
|
+
// serialize them: the first diffs the create against the pre-create set
|
|
260
|
+
// and updates `known`; the second sees no change. Without the chain both
|
|
261
|
+
// would snapshot concurrently against the stale `known` and emit twice.
|
|
262
|
+
const a = w.refresh();
|
|
263
|
+
const b = w.refresh();
|
|
264
|
+
await Promise.all([a, b]);
|
|
265
|
+
expect(got.filter((m) => m.action === 'added' && m.slug === 'ui-pair')).toHaveLength(1);
|
|
266
|
+
} finally {
|
|
267
|
+
w.stop();
|
|
268
|
+
rmSync(root, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('removing a whole group subdir emits one removed per canvas (git-checkout shape)', async () => {
|
|
273
|
+
const { root, designRoot } = sandbox();
|
|
274
|
+
mkdirSync(join(designRoot, 'ui', 'project'), { recursive: true });
|
|
275
|
+
writeFileSync(join(designRoot, 'ui', 'project', 'A.tsx'), TSX);
|
|
276
|
+
writeFileSync(join(designRoot, 'ui', 'project', 'B.tsx'), TSX);
|
|
277
|
+
const ctx = mkCtx(designRoot);
|
|
278
|
+
const got = collect(ctx);
|
|
279
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
280
|
+
await w.ready;
|
|
281
|
+
try {
|
|
282
|
+
rmSync(join(designRoot, 'ui', 'project'), { recursive: true, force: true });
|
|
283
|
+
await w.refresh();
|
|
284
|
+
const removed = got.filter((m) => m.action === 'removed').map((m) => m.slug);
|
|
285
|
+
expect(removed.sort()).toEqual(['ui-project-a', 'ui-project-b']);
|
|
286
|
+
} finally {
|
|
287
|
+
w.stop();
|
|
288
|
+
rmSync(root, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('canvas-list-watch / real fs-watch end-to-end', () => {
|
|
294
|
+
test('a raw disk write through createFsWatch emits added (the actual bug path)', async () => {
|
|
295
|
+
const { root, designRoot } = sandbox();
|
|
296
|
+
const ctx = mkCtx(designRoot);
|
|
297
|
+
const got = collect(ctx);
|
|
298
|
+
const fsWatch = createFsWatch(ctx);
|
|
299
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 30 });
|
|
300
|
+
await w.ready;
|
|
301
|
+
fsWatch.start();
|
|
302
|
+
try {
|
|
303
|
+
// Let the OS watcher arm, then write straight to disk — NO manual bus
|
|
304
|
+
// emit. This exercises the genuine ACP-agent path: disk write →
|
|
305
|
+
// fs.watch → fs:any → gate → debounce → snapshot diff → canvas-list-update.
|
|
306
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
307
|
+
writeFileSync(join(designRoot, 'ui', 'Real.tsx'), TSX);
|
|
308
|
+
const deadline = Date.now() + 3000;
|
|
309
|
+
while (
|
|
310
|
+
Date.now() < deadline &&
|
|
311
|
+
!got.some((m) => m.action === 'added' && m.slug === 'ui-real')
|
|
312
|
+
) {
|
|
313
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
314
|
+
}
|
|
315
|
+
expect(got.some((m) => m.action === 'added' && m.slug === 'ui-real')).toBe(true);
|
|
316
|
+
} finally {
|
|
317
|
+
fsWatch.stop();
|
|
318
|
+
w.stop();
|
|
319
|
+
rmSync(root, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
}, 10_000);
|
|
322
|
+
});
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// Phase 4 T5 — `/_api/canvas-meta` GET/PATCH endpoint round-trip.
|
|
2
|
+
// DDR-115 — per-user camera split: `viewport` lives in a gitignored per-machine
|
|
3
|
+
// view file (`_canvas-state/<slug>.view.json`), NEVER inline in the versioned
|
|
4
|
+
// `.meta.json`. PATCH splits the lanes; GET merges them back.
|
|
2
5
|
//
|
|
3
6
|
// Verifies:
|
|
4
|
-
// - PATCH
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// - PATCH rejects non-finite
|
|
8
|
-
// - GET
|
|
9
|
-
// -
|
|
7
|
+
// - PATCH `viewport` leaves `.meta.json` BYTE-UNCHANGED + writes the view file
|
|
8
|
+
// (the mouse-move churn killer) — and a viewport-only patch on a meta-less
|
|
9
|
+
// canvas still succeeds
|
|
10
|
+
// - PATCH clamps zoom to [0.1, 4.0] / rejects non-finite coords (view file)
|
|
11
|
+
// - GET merges the view file's viewport into the returned meta
|
|
12
|
+
// - GET strips a stale inline viewport from a legacy meta
|
|
13
|
+
// - PATCH `layout` writes meta AND bumps `last_modified` (a real shared change)
|
|
14
|
+
// - PATCH layout persists position-only entries (DDR-027)
|
|
15
|
+
// - Paths that escape repoRoot are 404
|
|
10
16
|
|
|
11
17
|
import { describe, expect, test } from 'bun:test';
|
|
12
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
19
|
import { join } from 'node:path';
|
|
14
20
|
|
|
15
21
|
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
@@ -29,8 +35,13 @@ function repoRel(designRoot: string, abs: string): string {
|
|
|
29
35
|
return abs.startsWith(`${repoRoot}/`) ? abs.slice(repoRoot.length + 1) : abs;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
/** `_canvas-state/<slug>.view.json` for a `<designRoot>/ui/<Name>.tsx` canvas. */
|
|
39
|
+
function viewPath(designRoot: string, slug: string): string {
|
|
40
|
+
return join(designRoot, '_canvas-state', `${slug}.view.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
44
|
+
test('PATCH viewport leaves meta byte-unchanged + writes the view file (churn killer)', async () => {
|
|
34
45
|
const { root, designRoot } = makeSandbox();
|
|
35
46
|
const port = nextPort();
|
|
36
47
|
const proc = await bootServer(root, port);
|
|
@@ -47,6 +58,7 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
47
58
|
ai_context: { pinned_decisions: ['keep dc-* classes'] },
|
|
48
59
|
})
|
|
49
60
|
);
|
|
61
|
+
const before = readFileSync(metaAbs, 'utf8');
|
|
50
62
|
const file = repoRel(designRoot, tsxAbs);
|
|
51
63
|
|
|
52
64
|
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
@@ -56,16 +68,48 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
56
68
|
});
|
|
57
69
|
expect(r.status).toBe(200);
|
|
58
70
|
const merged = (await r.json()) as MetaShape;
|
|
71
|
+
// The returned object is GET-shaped: shared keys + the camera merged in.
|
|
59
72
|
expect(merged.title).toBe('Phase 4');
|
|
60
73
|
expect(merged.sections).toBeDefined();
|
|
61
74
|
expect(merged.ai_context).toBeDefined();
|
|
62
75
|
expect(merged.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
|
|
63
|
-
expect(typeof merged.last_modified).toBe('string');
|
|
64
76
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
expect(
|
|
68
|
-
|
|
77
|
+
// KEYSTONE — the versioned meta is byte-identical; no viewport, no
|
|
78
|
+
// last_modified bump on a viewport-only patch.
|
|
79
|
+
expect(readFileSync(metaAbs, 'utf8')).toBe(before);
|
|
80
|
+
|
|
81
|
+
// The camera landed in the gitignored per-machine view file.
|
|
82
|
+
const vAbs = viewPath(designRoot, 'ui-phase4');
|
|
83
|
+
expect(existsSync(vAbs)).toBe(true);
|
|
84
|
+
const view = JSON.parse(readFileSync(vAbs, 'utf8'));
|
|
85
|
+
expect(view.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
|
|
86
|
+
} finally {
|
|
87
|
+
await killProc(proc);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('PATCH viewport succeeds on a canvas with no meta yet (writes only the view file)', async () => {
|
|
92
|
+
const { root, designRoot } = makeSandbox();
|
|
93
|
+
const port = nextPort();
|
|
94
|
+
const proc = await bootServer(root, port);
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
97
|
+
const tsxAbs = join(designRoot, 'ui', 'NoMeta.tsx');
|
|
98
|
+
writeFileSync(tsxAbs, 'export default function N(){return <main/>}\n');
|
|
99
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
100
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
101
|
+
|
|
102
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 2 } } }),
|
|
106
|
+
});
|
|
107
|
+
expect(r.status).toBe(200);
|
|
108
|
+
const merged = (await r.json()) as MetaShape;
|
|
109
|
+
expect(merged.viewport).toEqual({ x: 1, y: 2, zoom: 2 });
|
|
110
|
+
// No meta was conjured into existence.
|
|
111
|
+
expect(existsSync(metaAbs)).toBe(false);
|
|
112
|
+
expect(existsSync(viewPath(designRoot, 'ui-nometa'))).toBe(true);
|
|
69
113
|
} finally {
|
|
70
114
|
await killProc(proc);
|
|
71
115
|
}
|
|
@@ -102,7 +146,7 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
102
146
|
}
|
|
103
147
|
});
|
|
104
148
|
|
|
105
|
-
test('PATCH ignores non-finite viewport (NaN/Infinity)', async () => {
|
|
149
|
+
test('PATCH ignores non-finite viewport (NaN/Infinity) — keeps the prior camera', async () => {
|
|
106
150
|
const { root, designRoot } = makeSandbox();
|
|
107
151
|
const port = nextPort();
|
|
108
152
|
const proc = await bootServer(root, port);
|
|
@@ -110,36 +154,44 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
110
154
|
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
111
155
|
const tsxAbs = join(designRoot, 'ui', 'Bad.tsx');
|
|
112
156
|
writeFileSync(tsxAbs, 'export default function B(){return <main/>}\n');
|
|
113
|
-
writeFileSync(
|
|
114
|
-
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
115
|
-
'{"title":"Bad","viewport":{"x":1,"y":2,"zoom":1}}'
|
|
116
|
-
);
|
|
157
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"Bad"}');
|
|
117
158
|
const file = repoRel(designRoot, tsxAbs);
|
|
118
159
|
|
|
160
|
+
// Seed a valid camera (writes the view file).
|
|
161
|
+
await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
162
|
+
method: 'PATCH',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 1 } } }),
|
|
165
|
+
});
|
|
166
|
+
|
|
119
167
|
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
120
168
|
method: 'PATCH',
|
|
121
169
|
headers: { 'content-type': 'application/json' },
|
|
122
170
|
body: JSON.stringify({ file, patch: { viewport: { x: 'nope', y: null, zoom: 1 } } }),
|
|
123
171
|
});
|
|
124
172
|
const m = (await r.json()) as MetaShape;
|
|
125
|
-
// Prior
|
|
173
|
+
// Prior valid camera preserved (the bad patch was a no-op for the view file).
|
|
126
174
|
expect(m.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
|
|
175
|
+
const view = JSON.parse(readFileSync(viewPath(designRoot, 'ui-bad'), 'utf8'));
|
|
176
|
+
expect(view.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
|
|
127
177
|
} finally {
|
|
128
178
|
await killProc(proc);
|
|
129
179
|
}
|
|
130
180
|
});
|
|
131
181
|
|
|
132
|
-
test('GET
|
|
182
|
+
test('GET merges the view file viewport into the returned meta', async () => {
|
|
133
183
|
const { root, designRoot } = makeSandbox();
|
|
134
184
|
const port = nextPort();
|
|
135
185
|
const proc = await bootServer(root, port);
|
|
136
186
|
try {
|
|
137
187
|
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
138
|
-
const tsxAbs = join(designRoot, 'ui', '
|
|
139
|
-
writeFileSync(tsxAbs, 'export default function
|
|
188
|
+
const tsxAbs = join(designRoot, 'ui', 'Merge.tsx');
|
|
189
|
+
writeFileSync(tsxAbs, 'export default function M(){return <main/>}\n');
|
|
190
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), JSON.stringify({ title: 'Merge' }));
|
|
191
|
+
mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
|
|
140
192
|
writeFileSync(
|
|
141
|
-
|
|
142
|
-
JSON.stringify({
|
|
193
|
+
viewPath(designRoot, 'ui-merge'),
|
|
194
|
+
JSON.stringify({ viewport: { x: 7, y: 8, zoom: 0.5 } })
|
|
143
195
|
);
|
|
144
196
|
const file = repoRel(designRoot, tsxAbs);
|
|
145
197
|
|
|
@@ -148,8 +200,69 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
148
200
|
);
|
|
149
201
|
expect(r.status).toBe(200);
|
|
150
202
|
const m = (await r.json()) as MetaShape;
|
|
151
|
-
expect(m.title).toBe('
|
|
152
|
-
expect(m.viewport
|
|
203
|
+
expect(m.title).toBe('Merge');
|
|
204
|
+
expect(m.viewport).toEqual({ x: 7, y: 8, zoom: 0.5 });
|
|
205
|
+
} finally {
|
|
206
|
+
await killProc(proc);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('GET strips a stale inline viewport from a legacy meta (no view file)', async () => {
|
|
211
|
+
const { root, designRoot } = makeSandbox();
|
|
212
|
+
const port = nextPort();
|
|
213
|
+
const proc = await bootServer(root, port);
|
|
214
|
+
try {
|
|
215
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
216
|
+
const tsxAbs = join(designRoot, 'ui', 'Legacy.tsx');
|
|
217
|
+
writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
|
|
218
|
+
// A pre-DDR-115 meta with the camera baked inline + a stale last_modified.
|
|
219
|
+
writeFileSync(
|
|
220
|
+
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
221
|
+
JSON.stringify({
|
|
222
|
+
title: 'Legacy',
|
|
223
|
+
viewport: { x: 5, y: 6, zoom: 0.5 },
|
|
224
|
+
last_modified: '2020-01-01T00:00:00.000Z',
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
228
|
+
|
|
229
|
+
const r = await fetch(
|
|
230
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
231
|
+
);
|
|
232
|
+
const m = (await r.json()) as MetaShape;
|
|
233
|
+
expect(m.title).toBe('Legacy');
|
|
234
|
+
// The stale inline camera + timestamp are NOT surfaced (no view file → none).
|
|
235
|
+
expect(m.viewport).toBeUndefined();
|
|
236
|
+
expect(m.last_modified).toBeUndefined();
|
|
237
|
+
} finally {
|
|
238
|
+
await killProc(proc);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('PATCH layout writes meta AND bumps last_modified (a real shared change)', async () => {
|
|
243
|
+
const { root, designRoot } = makeSandbox();
|
|
244
|
+
const port = nextPort();
|
|
245
|
+
const proc = await bootServer(root, port);
|
|
246
|
+
try {
|
|
247
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
248
|
+
const tsxAbs = join(designRoot, 'ui', 'Layout.tsx');
|
|
249
|
+
writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
|
|
250
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
251
|
+
writeFileSync(metaAbs, JSON.stringify({ title: 'L', sections: [] }));
|
|
252
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
253
|
+
|
|
254
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
255
|
+
method: 'PATCH',
|
|
256
|
+
headers: { 'content-type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({ file, patch: { layout: { artboards: [{ id: 'a', x: 0, y: 0 }] } } }),
|
|
258
|
+
});
|
|
259
|
+
expect(r.status).toBe(200);
|
|
260
|
+
const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
|
|
261
|
+
expect(onDisk.layout?.artboards).toBeDefined();
|
|
262
|
+
expect(typeof onDisk.last_modified).toBe('string');
|
|
263
|
+
expect(onDisk.title).toBe('L');
|
|
264
|
+
// The camera never leaks into the versioned meta.
|
|
265
|
+
expect(onDisk.viewport).toBeUndefined();
|
|
153
266
|
} finally {
|
|
154
267
|
await killProc(proc);
|
|
155
268
|
}
|
|
@@ -204,6 +317,27 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
204
317
|
}
|
|
205
318
|
});
|
|
206
319
|
|
|
320
|
+
test('PATCH viewport on a non-existent canvas is refused (404) and mints no file (DDR-115 F-A2)', async () => {
|
|
321
|
+
const { root, designRoot } = makeSandbox();
|
|
322
|
+
const port = nextPort();
|
|
323
|
+
const proc = await bootServer(root, port);
|
|
324
|
+
try {
|
|
325
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
326
|
+
// No .tsx written — the canvas does not exist.
|
|
327
|
+
const file = '.design/ui/Ghost.tsx';
|
|
328
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
329
|
+
method: 'PATCH',
|
|
330
|
+
headers: { 'content-type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 1 } } }),
|
|
332
|
+
});
|
|
333
|
+
expect(r.status).toBe(404);
|
|
334
|
+
// The untrusted-origin write was refused — no per-machine file minted.
|
|
335
|
+
expect(existsSync(viewPath(designRoot, 'ui-ghost'))).toBe(false);
|
|
336
|
+
} finally {
|
|
337
|
+
await killProc(proc);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
207
341
|
test('PATCH rejects paths that escape repoRoot', async () => {
|
|
208
342
|
const { root } = makeSandbox();
|
|
209
343
|
const port = nextPort();
|
|
@@ -67,6 +67,41 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
67
67
|
'/_api/edit-css',
|
|
68
68
|
'/_api/edit-text',
|
|
69
69
|
'/_api/edit-attr',
|
|
70
|
+
// Phase 27 (E2) — every /_api/git/* route is MAIN-ORIGIN ONLY: absent from
|
|
71
|
+
// CANVAS_SAFE_API + startCanvasServer's `routes` map. A GET from the
|
|
72
|
+
// canvas origin must 403 at the gate (not 405 from a reached handler),
|
|
73
|
+
// proving the route is unreachable on this origin. Guards the dual-
|
|
74
|
+
// allowlist invariant for the token-bearing publish/get-latest endpoints.
|
|
75
|
+
'/_api/git/status',
|
|
76
|
+
'/_api/git/log',
|
|
77
|
+
'/_api/git/diff',
|
|
78
|
+
'/_api/git/commit',
|
|
79
|
+
'/_api/git/discard',
|
|
80
|
+
'/_api/git/push',
|
|
81
|
+
'/_api/git/pull',
|
|
82
|
+
'/_api/git/resolve',
|
|
83
|
+
// Phase 29 (E4) — drafts: list + create + switch + fold are MAIN-ORIGIN ONLY.
|
|
84
|
+
'/_api/git/branches',
|
|
85
|
+
'/_api/git/branch',
|
|
86
|
+
'/_api/git/checkout',
|
|
87
|
+
'/_api/git/fold',
|
|
88
|
+
// Phase 28 (E3) — every /_api/github/* route is MAIN-ORIGIN ONLY (absent
|
|
89
|
+
// from CANVAS_SAFE_API + startCanvasServer's `routes` map) and token-bearing.
|
|
90
|
+
// The untrusted canvas iframe origin must never reach identity/create-repo/
|
|
91
|
+
// invite/repos — a request from this origin 403s at the gate. This is also
|
|
92
|
+
// the guard that the GitHub token (server-held, keychain) can never be
|
|
93
|
+
// exfiltrated to the canvas realm.
|
|
94
|
+
'/_api/github/identity',
|
|
95
|
+
'/_api/github/repos',
|
|
96
|
+
'/_api/github/create-repo',
|
|
97
|
+
'/_api/github/invite',
|
|
98
|
+
'/_api/github/clone',
|
|
99
|
+
'/_api/github/create-project',
|
|
100
|
+
'/_api/design/init',
|
|
101
|
+
// Phase 29 (E4) Door C — the hub-link credential write is MAIN-ORIGIN ONLY;
|
|
102
|
+
// the untrusted canvas origin must never reach it (it writes the global
|
|
103
|
+
// ~/.config/maude/hubs.json token store).
|
|
104
|
+
'/_api/hub/link',
|
|
70
105
|
'/package.json',
|
|
71
106
|
]) {
|
|
72
107
|
expect(await code(p)).toBe(403);
|