@1agh/maude 0.30.0 → 0.32.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 +788 -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 +796 -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 +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -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 +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -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 +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- 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 +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -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 +95 -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/readiness.test.ts +127 -0
- 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 +82 -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,112 @@
|
|
|
1
|
+
// acp/transcript — repo-level chat list + raw-transcript → clean-messages.
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { deleteChat, listChats, readChatMessages } from '../acp/transcript.ts';
|
|
9
|
+
|
|
10
|
+
let root: string;
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function seed(chatId: string, lines: object[]): string {
|
|
16
|
+
root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
|
|
17
|
+
const dir = join(root, '_chat');
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
writeFileSync(join(dir, `${chatId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('readChatMessages — raw → clean turns', () => {
|
|
24
|
+
test('aggregates agent updates into one assistant message; drops noise', () => {
|
|
25
|
+
const designRoot = seed('c1', [
|
|
26
|
+
{ ts: 1, role: 'user', text: 'make the button red' },
|
|
27
|
+
{
|
|
28
|
+
ts: 2,
|
|
29
|
+
role: 'agent',
|
|
30
|
+
update: { sessionUpdate: 'available_commands_update', availableCommands: [] },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
ts: 3,
|
|
34
|
+
role: 'agent',
|
|
35
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Sure, ' } },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
ts: 4,
|
|
39
|
+
role: 'agent',
|
|
40
|
+
update: {
|
|
41
|
+
sessionUpdate: 'tool_call',
|
|
42
|
+
toolCallId: 't1',
|
|
43
|
+
title: 'Edit Button.tsx',
|
|
44
|
+
kind: 'edit',
|
|
45
|
+
status: 'pending',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
ts: 5,
|
|
50
|
+
role: 'agent',
|
|
51
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done.' } },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
ts: 6,
|
|
55
|
+
role: 'agent',
|
|
56
|
+
update: { sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' },
|
|
57
|
+
},
|
|
58
|
+
{ ts: 7, role: 'agent', update: { sessionUpdate: 'usage_update', used: 10 } },
|
|
59
|
+
{ ts: 8, role: 'stop', stopReason: 'end_turn' },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const msgs = readChatMessages(designRoot, 'c1');
|
|
63
|
+
expect(msgs.length).toBe(2);
|
|
64
|
+
expect(msgs[0]).toEqual({
|
|
65
|
+
role: 'user',
|
|
66
|
+
parts: [{ type: 'text', text: 'make the button red' }],
|
|
67
|
+
});
|
|
68
|
+
expect(msgs[1].role).toBe('assistant');
|
|
69
|
+
// text parts merged across the tool call boundary order-preserving
|
|
70
|
+
const texts = msgs[1].parts.filter((p) => p.type === 'text').map((p) => p.text);
|
|
71
|
+
expect(texts.join('')).toBe('Sure, done.');
|
|
72
|
+
const tool = msgs[1].parts.find((p) => p.type === 'tool');
|
|
73
|
+
expect(tool?.toolName).toBe('Edit Button.tsx');
|
|
74
|
+
expect(tool?.done).toBe(true);
|
|
75
|
+
// noise (available_commands_update / usage_update) is not rendered
|
|
76
|
+
expect(JSON.stringify(msgs)).not.toContain('availableCommands');
|
|
77
|
+
expect(JSON.stringify(msgs)).not.toContain('usage');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('missing chat → empty', () => {
|
|
81
|
+
const designRoot = seed('c1', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
82
|
+
expect(readChatMessages(designRoot, 'nope')).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('listChats', () => {
|
|
87
|
+
test('lists chats with a title derived from the first user line', () => {
|
|
88
|
+
const designRoot = seed('chat-7', [
|
|
89
|
+
{ ts: 1, role: 'user', text: 'redesign the pricing page please' },
|
|
90
|
+
{ ts: 2, role: 'stop', stopReason: 'end_turn' },
|
|
91
|
+
]);
|
|
92
|
+
const chats = listChats(designRoot);
|
|
93
|
+
expect(chats.length).toBe(1);
|
|
94
|
+
expect(chats[0].id).toBe('chat-7');
|
|
95
|
+
expect(chats[0].title).toBe('redesign the pricing page please');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('no _chat dir → empty', () => {
|
|
99
|
+
root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
|
|
100
|
+
expect(listChats(root)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('deleteChat', () => {
|
|
105
|
+
test('removes the transcript; missing → false', () => {
|
|
106
|
+
const designRoot = seed('gone', [{ ts: 1, role: 'user', text: 'bye' }]);
|
|
107
|
+
expect(listChats(designRoot).length).toBe(1);
|
|
108
|
+
expect(deleteChat(designRoot, 'gone')).toBe(true);
|
|
109
|
+
expect(listChats(designRoot).length).toBe(0);
|
|
110
|
+
expect(deleteChat(designRoot, 'gone')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -122,6 +122,71 @@ describe('/_api/canvas — POST round-trip', () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
|
|
126
|
+
const { root } = makeSandbox();
|
|
127
|
+
const port = nextPort();
|
|
128
|
+
const proc = await bootServer(root, port);
|
|
129
|
+
let socket: WebSocket | null = null;
|
|
130
|
+
try {
|
|
131
|
+
const messages: Array<{ type: string; payload?: { action?: string; slug?: string } }> = [];
|
|
132
|
+
const sock = new WebSocket(`ws://localhost:${port}/_ws`);
|
|
133
|
+
socket = sock;
|
|
134
|
+
await new Promise<void>((res, rej) => {
|
|
135
|
+
sock.addEventListener('open', () => res());
|
|
136
|
+
sock.addEventListener('error', () => rej(new Error('ws error')));
|
|
137
|
+
setTimeout(() => rej(new Error('ws open timeout')), 2000);
|
|
138
|
+
});
|
|
139
|
+
sock.addEventListener('message', (ev) => {
|
|
140
|
+
try {
|
|
141
|
+
messages.push(JSON.parse(String(ev.data)));
|
|
142
|
+
} catch {
|
|
143
|
+
/* ignore non-JSON */
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const waitFor = async (pred: (m: (typeof messages)[number]) => boolean) => {
|
|
148
|
+
const deadline = Date.now() + 2000;
|
|
149
|
+
while (Date.now() < deadline) {
|
|
150
|
+
const hit = messages.find(pred);
|
|
151
|
+
if (hit) return hit;
|
|
152
|
+
await Bun.sleep(25);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const created = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ name: 'Live Add', kind: 'brief-board' }),
|
|
161
|
+
});
|
|
162
|
+
expect(created.status).toBe(201);
|
|
163
|
+
const cj = (await created.json()) as { rel: string };
|
|
164
|
+
|
|
165
|
+
const added = await waitFor(
|
|
166
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'added'
|
|
167
|
+
);
|
|
168
|
+
expect(added?.payload?.slug).toBe('ui-live_add');
|
|
169
|
+
|
|
170
|
+
const del = await fetch(
|
|
171
|
+
`http://localhost:${port}/_api/canvas?file=${encodeURIComponent(cj.rel)}`,
|
|
172
|
+
{ method: 'DELETE' }
|
|
173
|
+
);
|
|
174
|
+
expect(del.status).toBe(200);
|
|
175
|
+
|
|
176
|
+
const removed = await waitFor(
|
|
177
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'removed'
|
|
178
|
+
);
|
|
179
|
+
expect(removed?.payload?.slug).toBe('ui-live_add');
|
|
180
|
+
} finally {
|
|
181
|
+
try {
|
|
182
|
+
socket?.close();
|
|
183
|
+
} catch {
|
|
184
|
+
/* ignore */
|
|
185
|
+
}
|
|
186
|
+
await killProc(proc);
|
|
187
|
+
}
|
|
188
|
+
}, 10_000);
|
|
189
|
+
|
|
125
190
|
test('defaults kind to brief-board when omitted', async () => {
|
|
126
191
|
const { root } = makeSandbox();
|
|
127
192
|
const port = nextPort();
|
|
@@ -284,6 +349,10 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
|
|
|
284
349
|
join(designRoot, `${created.slug}.annotations.svg`),
|
|
285
350
|
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>'
|
|
286
351
|
);
|
|
352
|
+
// Seed the per-machine camera view file (DDR-115) to prove it's swept too.
|
|
353
|
+
mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
|
|
354
|
+
const viewAbs = join(designRoot, '_canvas-state', `${created.slug}.view.json`);
|
|
355
|
+
writeFileSync(viewAbs, JSON.stringify({ viewport: { x: 1, y: 2, zoom: 1 } }));
|
|
287
356
|
const tsx = join(designRoot, 'ui', 'Trash Me.tsx');
|
|
288
357
|
expect(existsSync(tsx)).toBe(true);
|
|
289
358
|
|
|
@@ -293,14 +362,17 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
|
|
|
293
362
|
expect(j.ok).toBe(true);
|
|
294
363
|
expect(j.trashed.some((t) => t.endsWith('Trash Me.tsx'))).toBe(true);
|
|
295
364
|
expect(j.trashed.some((t) => t.endsWith('.annotations.svg'))).toBe(true);
|
|
365
|
+
expect(j.trashed.some((t) => t.endsWith('.view.json'))).toBe(true);
|
|
296
366
|
|
|
297
367
|
// Gone from the group; present (recoverable) under _trash/ with a manifest.
|
|
298
368
|
expect(existsSync(tsx)).toBe(false);
|
|
299
369
|
expect(existsSync(join(designRoot, `${created.slug}.annotations.svg`))).toBe(false);
|
|
370
|
+
expect(existsSync(viewAbs)).toBe(false);
|
|
300
371
|
const trashDirs = readdirSync(join(designRoot, '_trash'));
|
|
301
372
|
expect(trashDirs.length).toBe(1);
|
|
302
373
|
const td = join(designRoot, '_trash', trashDirs[0] as string);
|
|
303
374
|
expect(existsSync(join(td, 'Trash Me.tsx'))).toBe(true);
|
|
375
|
+
expect(existsSync(join(td, `_canvas-state__${created.slug}.view.json`))).toBe(true);
|
|
304
376
|
expect(existsSync(join(td, '_trash-manifest.json'))).toBe(true);
|
|
305
377
|
} finally {
|
|
306
378
|
await killProc(proc);
|
|
@@ -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
|
+
});
|