@1agh/maude 0.29.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 +6 -6
- 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/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- 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 +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- 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 +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- 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/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- 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/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- 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/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- 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 +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -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/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- 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 +58 -12
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
applyEdit,
|
|
10
|
+
applyRemove,
|
|
11
|
+
applyTextEdit,
|
|
12
|
+
CanvasEditError,
|
|
13
|
+
editAttribute,
|
|
14
|
+
} from '../canvas-edit.ts';
|
|
9
15
|
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
10
16
|
|
|
11
17
|
const CANVAS = '/abs/Canvas.tsx';
|
|
@@ -52,6 +58,42 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
52
58
|
expect(out.source).toBe(`function Demo() { return <button aria-label="new">x</button>; }`);
|
|
53
59
|
});
|
|
54
60
|
|
|
61
|
+
test('JSX-attribute-escapes a " in a replaced literal value (no JSON \\" corruption)', () => {
|
|
62
|
+
// Regression: the replace branch used to emit JSON.stringify(value) → `\"`,
|
|
63
|
+
// which is invalid JS-escaping inside a JSX attribute and corrupts the source.
|
|
64
|
+
// It must land as the HTML entity " instead. See DDR-105.
|
|
65
|
+
const src = `function Demo() { return <button aria-label="old">x</button>; }`;
|
|
66
|
+
const ids = idsOf(src);
|
|
67
|
+
const id = ids.button as string;
|
|
68
|
+
const out = applyEdit(CANVAS, src, id, 'aria-label', 'say "hi" <b>');
|
|
69
|
+
expect(out.source).toBe(
|
|
70
|
+
`function Demo() { return <button aria-label="say "hi" <b>">x</button>; }`
|
|
71
|
+
);
|
|
72
|
+
// Never the JS-escaped form that broke things.
|
|
73
|
+
expect(out.source).not.toContain('\\"');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('JSX-attribute-escapes a " in a newly inserted attribute value', () => {
|
|
77
|
+
const src = 'function Demo() { return <div>x</div>; }';
|
|
78
|
+
const ids = idsOf(src);
|
|
79
|
+
const id = ids.div as string;
|
|
80
|
+
const out = applyEdit(CANVAS, src, id, 'title', 'a "quoted" title');
|
|
81
|
+
expect(out.source).toBe(
|
|
82
|
+
`function Demo() { return <div title="a "quoted" title">x</div>; }`
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('replaces an expression-container attr value with a JSX-escaped literal', () => {
|
|
87
|
+
// <Tag name={expr} /> → <Tag name="value" />, entity-escaped (not JSON-escaped).
|
|
88
|
+
const src = `function Demo() { return <button aria-label={lbl}>x</button>; }`;
|
|
89
|
+
const ids = idsOf(src);
|
|
90
|
+
const id = ids.button as string;
|
|
91
|
+
const out = applyEdit(CANVAS, src, id, 'aria-label', 'with "quote"');
|
|
92
|
+
expect(out.source).toBe(
|
|
93
|
+
`function Demo() { return <button aria-label="with "quote"">x</button>; }`
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
55
97
|
test('swaps a single style.<prop> value inside style={{...}}', () => {
|
|
56
98
|
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
57
99
|
const ids = idsOf(src);
|
|
@@ -119,6 +161,62 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
119
161
|
});
|
|
120
162
|
});
|
|
121
163
|
|
|
164
|
+
describe('canvas-edit / applyRemove (reset)', () => {
|
|
165
|
+
test('removes one style key, leaving the siblings + object intact', () => {
|
|
166
|
+
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
167
|
+
const ids = idsOf(src);
|
|
168
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.margin');
|
|
169
|
+
expect(out.source).toBe('function Demo() { return <div style={{ padding: 8 }}>x</div>; }');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('removes the leading style key (consumes the trailing comma)', () => {
|
|
173
|
+
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
174
|
+
const ids = idsOf(src);
|
|
175
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.padding');
|
|
176
|
+
expect(out.source).toBe('function Demo() { return <div style={{ margin: 4 }}>x</div>; }');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('removing the only style key drops the whole style attribute', () => {
|
|
180
|
+
const src = 'function Demo() { return <div style={{ padding: 8 }}>x</div>; }';
|
|
181
|
+
const ids = idsOf(src);
|
|
182
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.padding');
|
|
183
|
+
expect(out.source).toBe('function Demo() { return <div>x</div>; }');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('accepts a kebab style prop name (border-radius → borderRadius)', () => {
|
|
187
|
+
const src = 'function Demo() { return <div style={{ borderRadius: 8, padding: 4 }}>x</div>; }';
|
|
188
|
+
const ids = idsOf(src);
|
|
189
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.border-radius');
|
|
190
|
+
expect(out.source).toBe('function Demo() { return <div style={{ padding: 4 }}>x</div>; }');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('removes a plain attribute (custom data-*)', () => {
|
|
194
|
+
const src = 'function Demo() { return <button data-state="active">x</button>; }';
|
|
195
|
+
const ids = idsOf(src);
|
|
196
|
+
const out = applyRemove(CANVAS, src, ids.button as string, 'data-state');
|
|
197
|
+
expect(out.source).toBe('function Demo() { return <button>x</button>; }');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('is a no-op (delta 0) when the key / attribute is absent', () => {
|
|
201
|
+
const src = 'function Demo() { return <div style={{ padding: 8 }}>x</div>; }';
|
|
202
|
+
const ids = idsOf(src);
|
|
203
|
+
const a = applyRemove(CANVAS, src, ids.div as string, 'style.margin');
|
|
204
|
+
expect(a.source).toBe(src);
|
|
205
|
+
expect(a.delta).toBe(0);
|
|
206
|
+
const b = applyRemove(CANVAS, src, ids.div as string, 'data-nope');
|
|
207
|
+
expect(b.source).toBe(src);
|
|
208
|
+
expect(b.delta).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('refuses to remove data-cd-id (pipeline-owned)', () => {
|
|
212
|
+
const src = 'function Demo() { return <div>x</div>; }';
|
|
213
|
+
const ids = idsOf(src);
|
|
214
|
+
expect(() => applyRemove(CANVAS, src, ids.div as string, 'data-cd-id')).toThrow(
|
|
215
|
+
CanvasEditError
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
122
220
|
describe('canvas-edit / editAttribute (fs)', () => {
|
|
123
221
|
test('writes the edit atomically + is a no-op when source unchanged', async () => {
|
|
124
222
|
const tmp = `/tmp/canvas-edit-${Math.random().toString(36).slice(2)}.tsx`;
|
|
@@ -137,3 +235,85 @@ describe('canvas-edit / editAttribute (fs)', () => {
|
|
|
137
235
|
expect(r2.delta).toBe(0);
|
|
138
236
|
});
|
|
139
237
|
});
|
|
238
|
+
|
|
239
|
+
// Phase 12 (DDR-103) — inline text-content edit. Leaf-text only; JSX-escaped.
|
|
240
|
+
describe('canvas-edit / applyTextEdit', () => {
|
|
241
|
+
test('overwrites a leaf text node', () => {
|
|
242
|
+
const src = `function Demo() { return <button>Save</button>; }`;
|
|
243
|
+
const id = idsOf(src).button as string;
|
|
244
|
+
const out = applyTextEdit(CANVAS, src, id, 'Uložit');
|
|
245
|
+
expect(out.source).toBe(`function Demo() { return <button>Uložit</button>; }`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('preserves surrounding whitespace / indentation', () => {
|
|
249
|
+
const src = [
|
|
250
|
+
'function Demo() {',
|
|
251
|
+
' return (',
|
|
252
|
+
' <button>',
|
|
253
|
+
' Save',
|
|
254
|
+
' </button>',
|
|
255
|
+
' );',
|
|
256
|
+
'}',
|
|
257
|
+
].join('\n');
|
|
258
|
+
const id = idsOf(src).button as string;
|
|
259
|
+
const out = applyTextEdit(CANVAS, src, id, 'Go');
|
|
260
|
+
expect(out.source).toContain(' <button>\n Go\n </button>');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('JSX-escapes <, >, {, }, & so text can never become markup or an expression', () => {
|
|
264
|
+
const src = `function Demo() { return <code>x</code>; }`;
|
|
265
|
+
const id = idsOf(src).code as string;
|
|
266
|
+
const out = applyTextEdit(CANVAS, src, id, 'a < b && c > {d}');
|
|
267
|
+
expect(out.source).toContain('a < b && c > {d}');
|
|
268
|
+
expect(out.source).not.toContain('{d}');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('refuses an element with mixed (element) children', () => {
|
|
272
|
+
const src = `function Demo() { return <div>hi <b>there</b></div>; }`;
|
|
273
|
+
const id = idsOf(src).div as string;
|
|
274
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('refuses an element whose only child is a JSX expression', () => {
|
|
278
|
+
const src = 'function Demo() { const t = "x"; return <h1>{t}</h1>; }';
|
|
279
|
+
const id = idsOf(src).h1 as string;
|
|
280
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('refuses a self-closing / empty element (no text to edit)', () => {
|
|
284
|
+
const src = `function Demo() { return <div className="x" />; }`;
|
|
285
|
+
const id = idsOf(src).div as string;
|
|
286
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('throws on a missing id', () => {
|
|
290
|
+
const src = `function Demo() { return <button>Save</button>; }`;
|
|
291
|
+
expect(() => applyTextEdit(CANVAS, src, 'deadbeef', 'x')).toThrow(CanvasEditError);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Phase 12 (DDR-103) — the `style.<prop>` write path the CSS knobs ride
|
|
296
|
+
// (api.editCss maps property → camelCase + always quotes the value).
|
|
297
|
+
describe('canvas-edit / applyEdit style.<prop> (CSS-knob path)', () => {
|
|
298
|
+
test('inserts an inline style object when none exists', () => {
|
|
299
|
+
const src = `function Demo() { return <div>x</div>; }`;
|
|
300
|
+
const id = idsOf(src).div as string;
|
|
301
|
+
const out = applyEdit(CANVAS, src, id, 'style.borderRadius', '"8px"');
|
|
302
|
+
expect(out.source).toContain('style={{ borderRadius: "8px" }}');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('overwrites an existing style key, leaving siblings intact', () => {
|
|
306
|
+
const src = `function Demo() { return <div style={{ padding: 4, color: "red" }}>x</div>; }`;
|
|
307
|
+
const id = idsOf(src).div as string;
|
|
308
|
+
const out = applyEdit(CANVAS, src, id, 'style.color', '"blue"');
|
|
309
|
+
expect(out.source).toContain('color: "blue"');
|
|
310
|
+
expect(out.source).toContain('padding: 4');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('accepts a token-valued (var(--…)) string for on-system edits', () => {
|
|
314
|
+
const src = `function Demo() { return <div>x</div>; }`;
|
|
315
|
+
const id = idsOf(src).div as string;
|
|
316
|
+
const out = applyEdit(CANVAS, src, id, 'style.padding', '"var(--space-3)"');
|
|
317
|
+
expect(out.source).toContain('style={{ padding: "var(--space-3)" }}');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -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
|
+
});
|