@1agh/maude 0.39.1 → 0.41.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 +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -12,11 +12,14 @@ import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
type ArrowStroke,
|
|
15
|
+
type EllipseStroke,
|
|
16
|
+
type SectionStroke,
|
|
15
17
|
type StickyStroke,
|
|
16
18
|
type Stroke,
|
|
17
19
|
sanitizeAnnotationSvg,
|
|
18
20
|
strokesToSvg,
|
|
19
21
|
svgToStrokes,
|
|
22
|
+
type TextStroke,
|
|
20
23
|
} from '../annotations-model.ts';
|
|
21
24
|
|
|
22
25
|
const BIN = new URL('../bin/annotate.mjs', import.meta.url).pathname;
|
|
@@ -162,6 +165,171 @@ describe('annotate --ops', () => {
|
|
|
162
165
|
expect(readSvg()).toBe(before);
|
|
163
166
|
});
|
|
164
167
|
|
|
168
|
+
test('--in places inside the artboard bounds (top-left + inset)', () => {
|
|
169
|
+
const manifest = JSON.stringify({
|
|
170
|
+
artboards: [{ id: 'hero', x: 1000, y: 2000, w: 400, h: 300 }],
|
|
171
|
+
elements: [],
|
|
172
|
+
elementsTruncated: false,
|
|
173
|
+
});
|
|
174
|
+
writeFileSync(join(root, 'rects.json'), manifest);
|
|
175
|
+
const res = runAnnotate(
|
|
176
|
+
['ui/Pin.tsx', '--root', root, '--rects', join(root, 'rects.json'), '--in', 'hero'],
|
|
177
|
+
JSON.stringify({ ops: [{ op: 'create', type: 'sticky', text: 'inside hero' }] })
|
|
178
|
+
);
|
|
179
|
+
expect(res.code).toBe(0);
|
|
180
|
+
const svg = readFileSync(join(root, '.design', 'ui-pin.annotations.svg'), 'utf8');
|
|
181
|
+
const [sticky] = svgToStrokes(svg).filter((s): s is StickyStroke => s.tool === 'sticky');
|
|
182
|
+
expect(sticky?.x).toBe(1040); // hero.x + 40 inset
|
|
183
|
+
expect(sticky?.y).toBe(2040); // hero.y + 40 inset
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('--in with an unknown artboard fails loud, writes nothing', () => {
|
|
187
|
+
const manifest = JSON.stringify({
|
|
188
|
+
artboards: [{ id: 'hero', x: 0, y: 0, w: 400, h: 300 }],
|
|
189
|
+
elements: [],
|
|
190
|
+
elementsTruncated: false,
|
|
191
|
+
});
|
|
192
|
+
writeFileSync(join(root, 'rects2.json'), manifest);
|
|
193
|
+
const res = runAnnotate(
|
|
194
|
+
['ui/PinMissing.tsx', '--root', root, '--rects', join(root, 'rects2.json'), '--in', 'ghost'],
|
|
195
|
+
JSON.stringify({ ops: [{ op: 'create', type: 'sticky', text: 'x' }] })
|
|
196
|
+
);
|
|
197
|
+
expect(res.code).toBe(2);
|
|
198
|
+
expect(res.err).toMatch(/unknown artboard "ghost"/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('--pin places beside the element and draws a pointer arrow to it', () => {
|
|
202
|
+
const manifest = JSON.stringify({
|
|
203
|
+
artboards: [{ id: 'hero', x: 0, y: 0, w: 1000, h: 800 }],
|
|
204
|
+
elements: [
|
|
205
|
+
{
|
|
206
|
+
cdId: 'cta1',
|
|
207
|
+
selector: '[data-dc-screen="hero"] [data-cd-id="cta1"]',
|
|
208
|
+
index: 0,
|
|
209
|
+
artboard: 'hero',
|
|
210
|
+
x: 500,
|
|
211
|
+
y: 500,
|
|
212
|
+
w: 120,
|
|
213
|
+
h: 40,
|
|
214
|
+
tag: 'button',
|
|
215
|
+
text: 'Continue',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
elementsTruncated: false,
|
|
219
|
+
});
|
|
220
|
+
writeFileSync(join(root, 'rects3.json'), manifest);
|
|
221
|
+
const res = runAnnotate(
|
|
222
|
+
['ui/Callout.tsx', '--root', root, '--rects', join(root, 'rects3.json'), '--pin', 'cta1'],
|
|
223
|
+
JSON.stringify({ ops: [{ op: 'create', type: 'sticky', text: 'make this bigger' }] })
|
|
224
|
+
);
|
|
225
|
+
expect(res.code).toBe(0);
|
|
226
|
+
const svg = readFileSync(join(root, '.design', 'ui-callout.annotations.svg'), 'utf8');
|
|
227
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
228
|
+
const strokes = svgToStrokes(svg);
|
|
229
|
+
expect(strokesToSvg(strokes)).toBe(svg);
|
|
230
|
+
const [sticky] = strokes.filter((s): s is StickyStroke => s.tool === 'sticky');
|
|
231
|
+
expect(sticky?.x).toBe(660); // element.x (500) + element.w (120) + 40 gap
|
|
232
|
+
expect(sticky?.y).toBe(500); // element.y
|
|
233
|
+
const arrows = strokes.filter((s): s is ArrowStroke => s.tool === 'arrow');
|
|
234
|
+
expect(arrows).toHaveLength(1);
|
|
235
|
+
expect(arrows[0]?.author).toBe('ai');
|
|
236
|
+
// The pointer arrow is a snapshot, NOT a magnetic bind (no DOM host in this SVG).
|
|
237
|
+
expect(arrows[0]?.startBind).toBeUndefined();
|
|
238
|
+
expect(arrows[0]?.endBind).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('--pin to an unknown element fails loud, writes nothing', () => {
|
|
242
|
+
const manifest = JSON.stringify({ artboards: [], elements: [], elementsTruncated: false });
|
|
243
|
+
writeFileSync(join(root, 'rects4.json'), manifest);
|
|
244
|
+
const res = runAnnotate(
|
|
245
|
+
['ui/PinGhost.tsx', '--root', root, '--rects', join(root, 'rects4.json'), '--pin', 'ghost'],
|
|
246
|
+
JSON.stringify({ ops: [{ op: 'create', type: 'sticky', text: 'x' }] })
|
|
247
|
+
);
|
|
248
|
+
expect(res.code).toBe(2);
|
|
249
|
+
expect(res.err).toMatch(/element "ghost" not found/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('--no-pointer suppresses the arrow but keeps the placement', () => {
|
|
253
|
+
const manifest = JSON.stringify({
|
|
254
|
+
artboards: [],
|
|
255
|
+
elements: [
|
|
256
|
+
{
|
|
257
|
+
cdId: 'cta2',
|
|
258
|
+
selector: '[data-cd-id="cta2"]',
|
|
259
|
+
index: 0,
|
|
260
|
+
artboard: null,
|
|
261
|
+
x: 0,
|
|
262
|
+
y: 0,
|
|
263
|
+
w: 100,
|
|
264
|
+
h: 40,
|
|
265
|
+
tag: 'button',
|
|
266
|
+
text: 'Go',
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
elementsTruncated: false,
|
|
270
|
+
});
|
|
271
|
+
writeFileSync(join(root, 'rects5.json'), manifest);
|
|
272
|
+
const res = runAnnotate(
|
|
273
|
+
[
|
|
274
|
+
'ui/NoPointer.tsx',
|
|
275
|
+
'--root',
|
|
276
|
+
root,
|
|
277
|
+
'--rects',
|
|
278
|
+
join(root, 'rects5.json'),
|
|
279
|
+
'--pin',
|
|
280
|
+
'cta2',
|
|
281
|
+
'--no-pointer',
|
|
282
|
+
],
|
|
283
|
+
JSON.stringify({ ops: [{ op: 'create', type: 'sticky', text: 'quiet note' }] })
|
|
284
|
+
);
|
|
285
|
+
expect(res.code).toBe(0);
|
|
286
|
+
const strokes = svgToStrokes(
|
|
287
|
+
readFileSync(join(root, '.design', 'ui-nopointer.annotations.svg'), 'utf8')
|
|
288
|
+
);
|
|
289
|
+
expect(strokes.filter((s) => s.tool === 'sticky')).toHaveLength(1);
|
|
290
|
+
expect(strokes.filter((s) => s.tool === 'arrow')).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('per-op "pin" overrides the batch default for just that op', () => {
|
|
294
|
+
const manifest = JSON.stringify({
|
|
295
|
+
artboards: [],
|
|
296
|
+
elements: [
|
|
297
|
+
{
|
|
298
|
+
cdId: 'target',
|
|
299
|
+
selector: '[data-cd-id="target"]',
|
|
300
|
+
index: 0,
|
|
301
|
+
artboard: null,
|
|
302
|
+
x: 900,
|
|
303
|
+
y: 900,
|
|
304
|
+
w: 50,
|
|
305
|
+
h: 50,
|
|
306
|
+
tag: 'span',
|
|
307
|
+
text: 'here',
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
elementsTruncated: false,
|
|
311
|
+
});
|
|
312
|
+
writeFileSync(join(root, 'rects6.json'), manifest);
|
|
313
|
+
const res = runAnnotate(
|
|
314
|
+
['ui/OpPin.tsx', '--root', root, '--rects', join(root, 'rects6.json')],
|
|
315
|
+
JSON.stringify({
|
|
316
|
+
ops: [
|
|
317
|
+
{ op: 'create', type: 'sticky', ref: '@a', text: 'default placement', x: 5, y: 5 },
|
|
318
|
+
{ op: 'create', type: 'sticky', ref: '@b', text: 'pinned', pin: 'target' },
|
|
319
|
+
],
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
expect(res.code).toBe(0);
|
|
323
|
+
const strokes = svgToStrokes(
|
|
324
|
+
readFileSync(join(root, '.design', 'ui-oppin.annotations.svg'), 'utf8')
|
|
325
|
+
);
|
|
326
|
+
const pinned = strokes.find(
|
|
327
|
+
(s) => s.tool === 'sticky' && (s as StickyStroke).text === 'pinned'
|
|
328
|
+
);
|
|
329
|
+
expect((pinned as StickyStroke)?.x).toBe(990); // target.x (900) + target.w (50) + 40 gap
|
|
330
|
+
expect((pinned as StickyStroke)?.y).toBe(900);
|
|
331
|
+
});
|
|
332
|
+
|
|
165
333
|
test('non-loopback _server.json.url is refused — writes to file, never PUTs off-box (F2)', () => {
|
|
166
334
|
// A poisoned/foreign _server.json points the egress at an external host.
|
|
167
335
|
// The verb must NOT PUT there (SSRF/exfil); it falls back to the file write.
|
|
@@ -182,3 +350,725 @@ describe('annotate --ops', () => {
|
|
|
182
350
|
}
|
|
183
351
|
});
|
|
184
352
|
});
|
|
353
|
+
|
|
354
|
+
// feature-whiteboard-ai-toolkit — the generic --board template engine. Named
|
|
355
|
+
// presets (retro / kanban / social-calendar / roadmap / brainstorm /
|
|
356
|
+
// checklist / user-flow) aren't hardcoded in annotate.mjs — these specs are
|
|
357
|
+
// exactly the shape the `whiteboard` skill documents as fixtures.
|
|
358
|
+
describe('annotate --board', () => {
|
|
359
|
+
function sections(strokes: Stroke[]) {
|
|
360
|
+
return strokes
|
|
361
|
+
.filter((s): s is Stroke & { x: number; w: number; label: string } => s.tool === 'section')
|
|
362
|
+
.sort((a, b) => a.x - b.x);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function noOverlap(rects: Array<{ x: number; w: number }>) {
|
|
366
|
+
for (let i = 1; i < rects.length; i += 1) {
|
|
367
|
+
const prev = rects[i - 1] as { x: number; w: number };
|
|
368
|
+
const cur = rects[i] as { x: number; w: number };
|
|
369
|
+
expect(cur.x).toBeGreaterThanOrEqual(prev.x + prev.w);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
test('retro (columns, empty) — 3 evenly-spaced, non-overlapping blank sections', () => {
|
|
374
|
+
const spec = {
|
|
375
|
+
title: 'Sprint 42 retro',
|
|
376
|
+
groups: [
|
|
377
|
+
{ title: 'What went well', cards: [] },
|
|
378
|
+
{ title: 'What to improve', cards: [] },
|
|
379
|
+
{ title: 'Action items', cards: [] },
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
writeFileSync(join(root, 'retro-empty.json'), JSON.stringify(spec));
|
|
383
|
+
const res = runAnnotate([
|
|
384
|
+
'ui/RetroEmpty.tsx',
|
|
385
|
+
'--root',
|
|
386
|
+
root,
|
|
387
|
+
'--board',
|
|
388
|
+
join(root, 'retro-empty.json'),
|
|
389
|
+
]);
|
|
390
|
+
expect(res.code).toBe(0);
|
|
391
|
+
const svg = readFileSync(join(root, '.design', 'ui-retroempty.annotations.svg'), 'utf8');
|
|
392
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
393
|
+
const strokes = svgToStrokes(svg);
|
|
394
|
+
expect(strokesToSvg(strokes)).toBe(svg);
|
|
395
|
+
const secs = sections(strokes);
|
|
396
|
+
expect(secs).toHaveLength(3);
|
|
397
|
+
expect(secs.map((s) => s.label)).toEqual(['What went well', 'What to improve', 'Action items']);
|
|
398
|
+
// Every empty section gets the SAME clean default height.
|
|
399
|
+
expect(new Set(secs.map((s) => s.h)).size).toBe(1);
|
|
400
|
+
noOverlap(secs);
|
|
401
|
+
expect(strokes.filter((s) => s.tool === 'sticky')).toHaveLength(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('retro (columns, seeded) — cards stack inside their section without overlap', () => {
|
|
405
|
+
const spec = {
|
|
406
|
+
groups: [
|
|
407
|
+
{ title: 'Went well', cards: ['shipped on time', 'good pairing'] },
|
|
408
|
+
{ title: 'To improve', cards: ['too many meetings'] },
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
writeFileSync(join(root, 'retro-seeded.json'), JSON.stringify(spec));
|
|
412
|
+
const res = runAnnotate([
|
|
413
|
+
'ui/RetroSeeded.tsx',
|
|
414
|
+
'--root',
|
|
415
|
+
root,
|
|
416
|
+
'--board',
|
|
417
|
+
join(root, 'retro-seeded.json'),
|
|
418
|
+
]);
|
|
419
|
+
expect(res.code).toBe(0);
|
|
420
|
+
const strokes = svgToStrokes(
|
|
421
|
+
readFileSync(join(root, '.design', 'ui-retroseeded.annotations.svg'), 'utf8')
|
|
422
|
+
);
|
|
423
|
+
const secs = sections(strokes);
|
|
424
|
+
expect(secs).toHaveLength(2);
|
|
425
|
+
noOverlap(secs);
|
|
426
|
+
const stickies = strokes.filter((s): s is StickyStroke => s.tool === 'sticky');
|
|
427
|
+
expect(stickies.map((s) => s.text).sort()).toEqual(
|
|
428
|
+
['good pairing', 'shipped on time', 'too many meetings'].sort()
|
|
429
|
+
);
|
|
430
|
+
// The 2 cards in the first section stack vertically, not overlapping.
|
|
431
|
+
const wellCards = stickies.filter((s) => s.x === stickies[0]?.x);
|
|
432
|
+
const byY = [...wellCards].sort((a, b) => a.y - b.y);
|
|
433
|
+
for (let i = 1; i < byY.length; i += 1) {
|
|
434
|
+
const prev = byY[i - 1] as StickyStroke;
|
|
435
|
+
const cur = byY[i] as StickyStroke;
|
|
436
|
+
expect(cur.y).toBeGreaterThanOrEqual(prev.y + prev.h);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('social-calendar shape (7 columns) — 7 sections + 7 stickies, no overlap', () => {
|
|
441
|
+
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
442
|
+
const spec = { groups: days.map((d) => ({ title: d, cards: [`${d} post idea`] })) };
|
|
443
|
+
writeFileSync(join(root, 'calendar.json'), JSON.stringify(spec));
|
|
444
|
+
const res = runAnnotate([
|
|
445
|
+
'ui/Calendar.tsx',
|
|
446
|
+
'--root',
|
|
447
|
+
root,
|
|
448
|
+
'--board',
|
|
449
|
+
join(root, 'calendar.json'),
|
|
450
|
+
]);
|
|
451
|
+
expect(res.code).toBe(0);
|
|
452
|
+
const strokes = svgToStrokes(
|
|
453
|
+
readFileSync(join(root, '.design', 'ui-calendar.annotations.svg'), 'utf8')
|
|
454
|
+
);
|
|
455
|
+
const secs = sections(strokes);
|
|
456
|
+
expect(secs).toHaveLength(7);
|
|
457
|
+
expect(secs.map((s) => s.label)).toEqual(days);
|
|
458
|
+
noOverlap(secs);
|
|
459
|
+
expect(strokes.filter((s) => s.tool === 'sticky')).toHaveLength(7);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('user-flow (layout: "flow") delegates to the SAME auto-layout as --flow, reads back as a graph', () => {
|
|
463
|
+
const spec = {
|
|
464
|
+
layout: 'flow',
|
|
465
|
+
nodes: [
|
|
466
|
+
{ id: 'start', label: 'Landing' },
|
|
467
|
+
{ id: 'signup', label: 'Sign up' },
|
|
468
|
+
{ id: 'done', label: 'Onboarded', shape: 'ellipse' },
|
|
469
|
+
],
|
|
470
|
+
edges: [
|
|
471
|
+
{ from: 'start', to: 'signup' },
|
|
472
|
+
{ from: 'signup', to: 'done', label: 'verified' },
|
|
473
|
+
],
|
|
474
|
+
};
|
|
475
|
+
writeFileSync(join(root, 'userflow.json'), JSON.stringify(spec));
|
|
476
|
+
const res = runAnnotate([
|
|
477
|
+
'ui/UserFlow.tsx',
|
|
478
|
+
'--root',
|
|
479
|
+
root,
|
|
480
|
+
'--board',
|
|
481
|
+
join(root, 'userflow.json'),
|
|
482
|
+
]);
|
|
483
|
+
expect(res.code).toBe(0);
|
|
484
|
+
const svg = readFileSync(join(root, '.design', 'ui-userflow.annotations.svg'), 'utf8');
|
|
485
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
486
|
+
const strokes = svgToStrokes(svg);
|
|
487
|
+
expect(strokesToSvg(strokes)).toBe(svg);
|
|
488
|
+
const arrows = strokes.filter((s): s is ArrowStroke => s.tool === 'arrow');
|
|
489
|
+
expect(arrows).toHaveLength(2);
|
|
490
|
+
for (const a of arrows) {
|
|
491
|
+
expect(a.startBind?.hostId).toBeDefined();
|
|
492
|
+
expect(a.endBind?.hostId).toBeDefined();
|
|
493
|
+
}
|
|
494
|
+
const proc = Bun.spawnSync(['node', READER, 'ui/UserFlow.tsx', '--root', root, '--graph']);
|
|
495
|
+
const parsed = JSON.parse(new TextDecoder().decode(proc.stdout)) as {
|
|
496
|
+
graph: { nodes: Array<{ label: string | null }>; edges: unknown[] };
|
|
497
|
+
};
|
|
498
|
+
expect(parsed.graph.edges).toHaveLength(2);
|
|
499
|
+
expect(parsed.graph.nodes.map((n) => n.label).sort()).toEqual([
|
|
500
|
+
'Landing',
|
|
501
|
+
'Onboarded',
|
|
502
|
+
'Sign up',
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('brainstorm (layout: "radial") — a center topic + cards ringed around it', () => {
|
|
507
|
+
const spec = {
|
|
508
|
+
title: 'How do we grow retention?',
|
|
509
|
+
layout: 'radial',
|
|
510
|
+
groups: [{ title: 'ideas', cards: ['onboarding email', 'in-app tips', 'referral bonus'] }],
|
|
511
|
+
};
|
|
512
|
+
writeFileSync(join(root, 'brainstorm.json'), JSON.stringify(spec));
|
|
513
|
+
const res = runAnnotate([
|
|
514
|
+
'ui/Brainstorm.tsx',
|
|
515
|
+
'--root',
|
|
516
|
+
root,
|
|
517
|
+
'--board',
|
|
518
|
+
join(root, 'brainstorm.json'),
|
|
519
|
+
]);
|
|
520
|
+
expect(res.code).toBe(0);
|
|
521
|
+
const strokes = svgToStrokes(
|
|
522
|
+
readFileSync(join(root, '.design', 'ui-brainstorm.annotations.svg'), 'utf8')
|
|
523
|
+
);
|
|
524
|
+
const center = strokes.find((s) => s.tool === 'ellipse');
|
|
525
|
+
expect(center).toBeDefined();
|
|
526
|
+
const centerLabel = strokes.find(
|
|
527
|
+
(s) => s.tool === 'text' && (s as Stroke & { anchorId?: string }).anchorId === center?.id
|
|
528
|
+
);
|
|
529
|
+
expect((centerLabel as Stroke & { text: string })?.text).toBe('How do we grow retention?');
|
|
530
|
+
const ideas = strokes.filter((s): s is StickyStroke => s.tool === 'sticky');
|
|
531
|
+
expect(ideas).toHaveLength(3);
|
|
532
|
+
expect(ideas.map((s) => s.text).sort()).toEqual(
|
|
533
|
+
['in-app tips', 'onboarding email', 'referral bonus'].sort()
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('connections[] draws bound arrows between minted section refs', () => {
|
|
538
|
+
const spec = {
|
|
539
|
+
groups: [
|
|
540
|
+
{ title: 'Backlog', cards: ['idea A'] },
|
|
541
|
+
{ title: 'Done', cards: ['shipped B'] },
|
|
542
|
+
],
|
|
543
|
+
connections: [{ from: '@sec0', to: '@sec1', label: 'promoted' }],
|
|
544
|
+
};
|
|
545
|
+
writeFileSync(join(root, 'connected.json'), JSON.stringify(spec));
|
|
546
|
+
const res = runAnnotate([
|
|
547
|
+
'ui/Connected.tsx',
|
|
548
|
+
'--root',
|
|
549
|
+
root,
|
|
550
|
+
'--board',
|
|
551
|
+
join(root, 'connected.json'),
|
|
552
|
+
]);
|
|
553
|
+
expect(res.code).toBe(0);
|
|
554
|
+
const strokes = svgToStrokes(
|
|
555
|
+
readFileSync(join(root, '.design', 'ui-connected.annotations.svg'), 'utf8')
|
|
556
|
+
);
|
|
557
|
+
const arrows = strokes.filter((s): s is ArrowStroke => s.tool === 'arrow');
|
|
558
|
+
expect(arrows).toHaveLength(1);
|
|
559
|
+
expect(arrows[0]?.startBind?.hostId).toBeDefined();
|
|
560
|
+
expect(arrows[0]?.endBind?.hostId).toBeDefined();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('--board and --ops together are rejected as mutually exclusive', () => {
|
|
564
|
+
writeFileSync(join(root, 'excl.json'), JSON.stringify({ groups: [{ cards: [] }] }));
|
|
565
|
+
const res = runAnnotate([
|
|
566
|
+
'ui/Excl.tsx',
|
|
567
|
+
'--root',
|
|
568
|
+
root,
|
|
569
|
+
'--board',
|
|
570
|
+
join(root, 'excl.json'),
|
|
571
|
+
'--ops',
|
|
572
|
+
join(root, 'excl.json'),
|
|
573
|
+
]);
|
|
574
|
+
expect(res.code).toBe(2);
|
|
575
|
+
expect(res.err).toMatch(/mutually exclusive/);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('--near places the whole board beside an artboard', () => {
|
|
579
|
+
const manifest = JSON.stringify({
|
|
580
|
+
artboards: [{ id: 'hero', x: 2000, y: 3000, w: 400, h: 300 }],
|
|
581
|
+
elements: [],
|
|
582
|
+
elementsTruncated: false,
|
|
583
|
+
});
|
|
584
|
+
writeFileSync(join(root, 'rects-board.json'), manifest);
|
|
585
|
+
const spec = { groups: [{ title: 'Notes', cards: ['x'] }] };
|
|
586
|
+
writeFileSync(join(root, 'near-board.json'), JSON.stringify(spec));
|
|
587
|
+
const res = runAnnotate([
|
|
588
|
+
'ui/NearBoard.tsx',
|
|
589
|
+
'--root',
|
|
590
|
+
root,
|
|
591
|
+
'--rects',
|
|
592
|
+
join(root, 'rects-board.json'),
|
|
593
|
+
'--near',
|
|
594
|
+
'hero',
|
|
595
|
+
'--board',
|
|
596
|
+
join(root, 'near-board.json'),
|
|
597
|
+
]);
|
|
598
|
+
expect(res.code).toBe(0);
|
|
599
|
+
const strokes = svgToStrokes(
|
|
600
|
+
readFileSync(join(root, '.design', 'ui-nearboard.annotations.svg'), 'utf8')
|
|
601
|
+
);
|
|
602
|
+
const [sec] = sections(strokes);
|
|
603
|
+
expect(sec?.x).toBe(2000 + 400 + 80); // hero.x + hero.w + 80 gap (existing --near math)
|
|
604
|
+
expect(sec?.y).toBe(3000);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Security regression (feature-whiteboard-ai-toolkit review, F-board-dos):
|
|
608
|
+
// groups[]/cards[] had no size cap before expansion — MAX_ANNOTATIONS_BYTES
|
|
609
|
+
// only rejected AFTER full expansion + serialization, so a pathological
|
|
610
|
+
// spec could burn CPU/memory before hitting it. These fail loud up front.
|
|
611
|
+
test('--board rejects a groups[] array over the cap', () => {
|
|
612
|
+
const spec = { groups: Array.from({ length: 25 }, (_, i) => ({ title: `g${i}`, cards: [] })) };
|
|
613
|
+
writeFileSync(join(root, 'board-toomanygroups.json'), JSON.stringify(spec));
|
|
614
|
+
const res = runAnnotate([
|
|
615
|
+
'ui/BoardTooManyGroups.tsx',
|
|
616
|
+
'--root',
|
|
617
|
+
root,
|
|
618
|
+
'--board',
|
|
619
|
+
join(root, 'board-toomanygroups.json'),
|
|
620
|
+
]);
|
|
621
|
+
expect(res.code).toBe(2);
|
|
622
|
+
expect(res.err).toMatch(/groups\[\] has 25, max 20/);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('--board rejects a single group with too many cards', () => {
|
|
626
|
+
const spec = { groups: [{ title: 'g', cards: Array.from({ length: 60 }, (_, i) => `c${i}`) }] };
|
|
627
|
+
writeFileSync(join(root, 'board-toomanycards.json'), JSON.stringify(spec));
|
|
628
|
+
const res = runAnnotate([
|
|
629
|
+
'ui/BoardTooManyCards.tsx',
|
|
630
|
+
'--root',
|
|
631
|
+
root,
|
|
632
|
+
'--board',
|
|
633
|
+
join(root, 'board-toomanycards.json'),
|
|
634
|
+
]);
|
|
635
|
+
expect(res.code).toBe(2);
|
|
636
|
+
expect(res.err).toMatch(/has 60 cards, max 50/);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test('--board rejects total cards over the cap even when spread across groups', () => {
|
|
640
|
+
const spec = {
|
|
641
|
+
groups: Array.from({ length: 20 }, (_, i) => ({
|
|
642
|
+
title: `g${i}`,
|
|
643
|
+
cards: Array.from({ length: 16 }, (_, j) => `c${i}-${j}`), // 20*16 = 320 > 300
|
|
644
|
+
})),
|
|
645
|
+
};
|
|
646
|
+
writeFileSync(join(root, 'board-toomanytotal.json'), JSON.stringify(spec));
|
|
647
|
+
const res = runAnnotate([
|
|
648
|
+
'ui/BoardTooManyTotal.tsx',
|
|
649
|
+
'--root',
|
|
650
|
+
root,
|
|
651
|
+
'--board',
|
|
652
|
+
join(root, 'board-toomanytotal.json'),
|
|
653
|
+
]);
|
|
654
|
+
expect(res.code).toBe(2);
|
|
655
|
+
expect(res.err).toMatch(/320 total cards across groups, max 300/);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('--board layout "flow" rejects a nodes[] array over the cap', () => {
|
|
659
|
+
const spec = {
|
|
660
|
+
layout: 'flow',
|
|
661
|
+
nodes: Array.from({ length: 201 }, (_, i) => ({ id: `n${i}`, label: `n${i}` })),
|
|
662
|
+
};
|
|
663
|
+
writeFileSync(join(root, 'board-toomanynodes.json'), JSON.stringify(spec));
|
|
664
|
+
const res = runAnnotate([
|
|
665
|
+
'ui/BoardTooManyNodes.tsx',
|
|
666
|
+
'--root',
|
|
667
|
+
root,
|
|
668
|
+
'--board',
|
|
669
|
+
join(root, 'board-toomanynodes.json'),
|
|
670
|
+
]);
|
|
671
|
+
expect(res.code).toBe(2);
|
|
672
|
+
expect(res.err).toMatch(/nodes\[\] has 201, max 200/);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// Security regression (feature-whiteboard-ai-toolkit review, F-connections-dos):
|
|
676
|
+
// groups[]/cards[]/nodes[] were capped but connections[]/edges[] were not —
|
|
677
|
+
// createConnect mints a fresh arrow (+ optional label) stroke per entry
|
|
678
|
+
// regardless of how few distinct nodes are involved, so a tiny board spec
|
|
679
|
+
// with a huge connections[]/edges[] array bypassed every existing cap.
|
|
680
|
+
test('--board rejects a connections[] array over the cap (columns layout)', () => {
|
|
681
|
+
const spec = {
|
|
682
|
+
groups: [{ title: 'g', cards: ['a', 'b'] }],
|
|
683
|
+
connections: Array.from({ length: 401 }, () => ({ from: '@sec0', to: '@sec0card0' })),
|
|
684
|
+
};
|
|
685
|
+
writeFileSync(join(root, 'board-toomanyconns.json'), JSON.stringify(spec));
|
|
686
|
+
const res = runAnnotate([
|
|
687
|
+
'ui/BoardTooManyConns.tsx',
|
|
688
|
+
'--root',
|
|
689
|
+
root,
|
|
690
|
+
'--board',
|
|
691
|
+
join(root, 'board-toomanyconns.json'),
|
|
692
|
+
]);
|
|
693
|
+
expect(res.code).toBe(2);
|
|
694
|
+
expect(res.err).toMatch(/connections\[\] has 401, max 400/);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('--flow (top-level) rejects an edges[] array over the cap', () => {
|
|
698
|
+
const spec = {
|
|
699
|
+
nodes: [
|
|
700
|
+
{ id: 'a', label: 'A' },
|
|
701
|
+
{ id: 'b', label: 'B' },
|
|
702
|
+
],
|
|
703
|
+
edges: Array.from({ length: 401 }, () => ({ from: 'a', to: 'b' })),
|
|
704
|
+
};
|
|
705
|
+
writeFileSync(join(root, 'flow-toomanyedges.json'), JSON.stringify(spec));
|
|
706
|
+
const res = runAnnotate([
|
|
707
|
+
'ui/FlowTooManyEdges.tsx',
|
|
708
|
+
'--root',
|
|
709
|
+
root,
|
|
710
|
+
'--flow',
|
|
711
|
+
join(root, 'flow-toomanyedges.json'),
|
|
712
|
+
]);
|
|
713
|
+
expect(res.code).toBe(2);
|
|
714
|
+
expect(res.err).toMatch(/edges\[\] has 401, max 400/);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('--flow (top-level) rejects a nodes[] array over the cap', () => {
|
|
718
|
+
const spec = {
|
|
719
|
+
nodes: Array.from({ length: 201 }, (_, i) => ({ id: `n${i}`, label: `n${i}` })),
|
|
720
|
+
};
|
|
721
|
+
writeFileSync(join(root, 'flow-toomanynodes.json'), JSON.stringify(spec));
|
|
722
|
+
const res = runAnnotate([
|
|
723
|
+
'ui/FlowTooManyNodes.tsx',
|
|
724
|
+
'--root',
|
|
725
|
+
root,
|
|
726
|
+
'--flow',
|
|
727
|
+
join(root, 'flow-toomanynodes.json'),
|
|
728
|
+
]);
|
|
729
|
+
expect(res.code).toBe(2);
|
|
730
|
+
expect(res.err).toMatch(/nodes\[\] has 201, max 200/);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// feature-whiteboard-ai-toolkit — id-preserving move/set-text/set-color.
|
|
735
|
+
// DDR-100 omitted "update"; these stay LWW-honest (whole-file write) but
|
|
736
|
+
// preserve the target's id AND every other attribute via the CANONICAL
|
|
737
|
+
// parser (svgToStrokes) + CANONICAL serializer round-trip.
|
|
738
|
+
describe('annotate move / set-text / set-color', () => {
|
|
739
|
+
// Mirrors api.ts fileSlug: "ui/Foo.tsx" -> "ui-foo" (the "/" replacement
|
|
740
|
+
// ALREADY produces the "ui-" prefix — no extra prefix on the filename).
|
|
741
|
+
function annotationsPath(canvasRel: string): string {
|
|
742
|
+
const slug = canvasRel
|
|
743
|
+
.replace(/\//g, '-')
|
|
744
|
+
.replace(/\.tsx$/, '')
|
|
745
|
+
.toLowerCase();
|
|
746
|
+
return join(root, '.design', `${slug}.annotations.svg`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function seedCanvas(canvasRel: string, strokes: Stroke[]): void {
|
|
750
|
+
writeFileSync(annotationsPath(canvasRel), strokesToSvg(strokes));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function readStrokes(canvasRel: string): Stroke[] {
|
|
754
|
+
return svgToStrokes(readFileSync(annotationsPath(canvasRel), 'utf8'));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
test('move preserves id + every other attribute (custom fontSize, bold, groupIds)', () => {
|
|
758
|
+
const sticky: StickyStroke = {
|
|
759
|
+
id: 'mv1',
|
|
760
|
+
tool: 'sticky',
|
|
761
|
+
color: '#fce8a6',
|
|
762
|
+
x: 10,
|
|
763
|
+
y: 10,
|
|
764
|
+
w: 200,
|
|
765
|
+
h: 100,
|
|
766
|
+
text: 'don’t drop my formatting',
|
|
767
|
+
fontSize: 22, // NON-default — the point of the DOM-parser approach
|
|
768
|
+
cornerRadius: 8,
|
|
769
|
+
bold: true,
|
|
770
|
+
groupIds: ['g1'],
|
|
771
|
+
};
|
|
772
|
+
seedCanvas('ui/Move.tsx', [sticky]);
|
|
773
|
+
const res = runAnnotate(
|
|
774
|
+
['ui/Move.tsx', '--root', root],
|
|
775
|
+
JSON.stringify({ ops: [{ op: 'move', id: 'mv1', x: 900, y: 900 }] })
|
|
776
|
+
);
|
|
777
|
+
expect(res.code).toBe(0);
|
|
778
|
+
const svg = readFileSync(join(root, '.design', 'ui-move.annotations.svg'), 'utf8');
|
|
779
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
780
|
+
const [after] = readStrokes('ui/Move.tsx') as [StickyStroke];
|
|
781
|
+
expect(after.id).toBe('mv1');
|
|
782
|
+
expect(after.x).toBe(900);
|
|
783
|
+
expect(after.y).toBe(900);
|
|
784
|
+
// Everything else survived byte-for-byte — the whole point of parsing
|
|
785
|
+
// through the canonical model instead of reconstructing from defaults.
|
|
786
|
+
expect(after.fontSize).toBe(22);
|
|
787
|
+
expect(after.bold).toBe(true);
|
|
788
|
+
expect(after.cornerRadius).toBe(8);
|
|
789
|
+
expect(after.groupIds).toEqual(['g1']);
|
|
790
|
+
expect(after.text).toBe('don’t drop my formatting');
|
|
791
|
+
expect(after.color).toBe('#fce8a6');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test('move on an ellipse converts x/y (top-left) to cx/cy, keeping rx/ry', () => {
|
|
795
|
+
const ell: EllipseStroke = {
|
|
796
|
+
id: 'mv2',
|
|
797
|
+
tool: 'ellipse',
|
|
798
|
+
color: '#30a46c',
|
|
799
|
+
width: 3,
|
|
800
|
+
cx: 100,
|
|
801
|
+
cy: 100,
|
|
802
|
+
rx: 40,
|
|
803
|
+
ry: 20,
|
|
804
|
+
fill: null,
|
|
805
|
+
};
|
|
806
|
+
seedCanvas('ui/MoveEllipse.tsx', [ell]);
|
|
807
|
+
// Target x/y is top-left (cx-rx, cy-ry) — the convention read-annotations uses.
|
|
808
|
+
const res = runAnnotate(
|
|
809
|
+
['ui/MoveEllipse.tsx', '--root', root],
|
|
810
|
+
JSON.stringify({ ops: [{ op: 'move', id: 'mv2', x: 500, y: 500 }] })
|
|
811
|
+
);
|
|
812
|
+
expect(res.code).toBe(0);
|
|
813
|
+
const [after] = readStrokes('ui/MoveEllipse.tsx') as [EllipseStroke];
|
|
814
|
+
expect(after.cx).toBe(540); // 500 + rx(40)
|
|
815
|
+
expect(after.cy).toBe(520); // 500 + ry(20)
|
|
816
|
+
expect(after.rx).toBe(40);
|
|
817
|
+
expect(after.ry).toBe(20);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test('set-text on a sticky patches "text", set-text on a section patches "label"', () => {
|
|
821
|
+
const sticky: StickyStroke = {
|
|
822
|
+
id: 'st-text',
|
|
823
|
+
tool: 'sticky',
|
|
824
|
+
color: '#fce8a6',
|
|
825
|
+
x: 0,
|
|
826
|
+
y: 0,
|
|
827
|
+
w: 200,
|
|
828
|
+
h: 100,
|
|
829
|
+
text: 'old text',
|
|
830
|
+
fontSize: 14,
|
|
831
|
+
cornerRadius: 8,
|
|
832
|
+
};
|
|
833
|
+
const section: SectionStroke = {
|
|
834
|
+
id: 'sec-text',
|
|
835
|
+
tool: 'section',
|
|
836
|
+
x: 300,
|
|
837
|
+
y: 0,
|
|
838
|
+
w: 280,
|
|
839
|
+
h: 200,
|
|
840
|
+
label: 'Old label',
|
|
841
|
+
color: '#8b8b94',
|
|
842
|
+
};
|
|
843
|
+
seedCanvas('ui/SetText.tsx', [sticky, section]);
|
|
844
|
+
const res = runAnnotate(
|
|
845
|
+
['ui/SetText.tsx', '--root', root],
|
|
846
|
+
JSON.stringify({
|
|
847
|
+
ops: [
|
|
848
|
+
{ op: 'set-text', id: 'st-text', text: 'new text' },
|
|
849
|
+
{ op: 'set-text', id: 'sec-text', text: 'New label' },
|
|
850
|
+
],
|
|
851
|
+
})
|
|
852
|
+
);
|
|
853
|
+
expect(res.code).toBe(0);
|
|
854
|
+
const strokes = readStrokes('ui/SetText.tsx');
|
|
855
|
+
const after = strokes.find((s) => s.id === 'st-text') as StickyStroke;
|
|
856
|
+
const afterSec = strokes.find((s) => s.id === 'sec-text') as SectionStroke;
|
|
857
|
+
expect(after.text).toBe('new text');
|
|
858
|
+
expect(after.x).toBe(0); // unchanged
|
|
859
|
+
expect(afterSec.label).toBe('New label');
|
|
860
|
+
expect(afterSec.color).toBe('#8b8b94'); // unchanged
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test('set-color patches only the color, keeping text/position', () => {
|
|
864
|
+
const t: TextStroke = {
|
|
865
|
+
id: 'col1',
|
|
866
|
+
tool: 'text',
|
|
867
|
+
color: '#1a1a1a',
|
|
868
|
+
fontSize: 14,
|
|
869
|
+
text: 'recolor me',
|
|
870
|
+
x: 50,
|
|
871
|
+
y: 50,
|
|
872
|
+
};
|
|
873
|
+
seedCanvas('ui/SetColor.tsx', [t]);
|
|
874
|
+
const res = runAnnotate(
|
|
875
|
+
['ui/SetColor.tsx', '--root', root],
|
|
876
|
+
JSON.stringify({ ops: [{ op: 'set-color', id: 'col1', color: '#e5484d' }] })
|
|
877
|
+
);
|
|
878
|
+
expect(res.code).toBe(0);
|
|
879
|
+
const [after] = readStrokes('ui/SetColor.tsx') as [TextStroke];
|
|
880
|
+
expect(after.color).toBe('#e5484d');
|
|
881
|
+
expect(after.text).toBe('recolor me');
|
|
882
|
+
expect(after.x).toBe(50);
|
|
883
|
+
expect(after.y).toBe(50);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('move/set-text/set-color work on a stroke created earlier in the SAME batch (@ref)', () => {
|
|
887
|
+
const res = runAnnotate(
|
|
888
|
+
['ui/SameBatch.tsx', '--root', root],
|
|
889
|
+
JSON.stringify({
|
|
890
|
+
ops: [
|
|
891
|
+
{ op: 'create', type: 'sticky', ref: '@a', text: 'v1', x: 5, y: 5 },
|
|
892
|
+
{ op: 'move', id: '@a', x: 700, y: 700 },
|
|
893
|
+
{ op: 'set-text', id: '@a', text: 'v2' },
|
|
894
|
+
{ op: 'set-color', id: '@a', color: '#111111' },
|
|
895
|
+
],
|
|
896
|
+
})
|
|
897
|
+
);
|
|
898
|
+
expect(res.code).toBe(0);
|
|
899
|
+
const [after] = readStrokes('ui/SameBatch.tsx') as [StickyStroke];
|
|
900
|
+
expect(after.x).toBe(700);
|
|
901
|
+
expect(after.y).toBe(700);
|
|
902
|
+
expect(after.text).toBe('v2');
|
|
903
|
+
expect(after.color).toBe('#111111');
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test('chained move + set-text on a PRE-EXISTING (seeded, not @ref) id accumulate — not clobber', () => {
|
|
907
|
+
// Regression: resolveMutable must check ctx.replaces before the cached
|
|
908
|
+
// "original" — otherwise a second op on the same existing id reads the
|
|
909
|
+
// stale pre-batch stroke and its patch overwrites the first op's change.
|
|
910
|
+
const sticky: StickyStroke = {
|
|
911
|
+
id: 'chain1',
|
|
912
|
+
tool: 'sticky',
|
|
913
|
+
color: '#fce8a6',
|
|
914
|
+
x: 10,
|
|
915
|
+
y: 10,
|
|
916
|
+
w: 200,
|
|
917
|
+
h: 100,
|
|
918
|
+
text: 'v1',
|
|
919
|
+
fontSize: 14,
|
|
920
|
+
cornerRadius: 8,
|
|
921
|
+
};
|
|
922
|
+
seedCanvas('ui/Chain.tsx', [sticky]);
|
|
923
|
+
const res = runAnnotate(
|
|
924
|
+
['ui/Chain.tsx', '--root', root],
|
|
925
|
+
JSON.stringify({
|
|
926
|
+
ops: [
|
|
927
|
+
{ op: 'move', id: 'chain1', x: 500, y: 500 },
|
|
928
|
+
{ op: 'set-text', id: 'chain1', text: 'v2' },
|
|
929
|
+
{ op: 'set-color', id: 'chain1', color: '#111111' },
|
|
930
|
+
],
|
|
931
|
+
})
|
|
932
|
+
);
|
|
933
|
+
expect(res.code).toBe(0);
|
|
934
|
+
const [after] = readStrokes('ui/Chain.tsx') as [StickyStroke];
|
|
935
|
+
expect(after.id).toBe('chain1');
|
|
936
|
+
expect(after.x).toBe(500);
|
|
937
|
+
expect(after.y).toBe(500);
|
|
938
|
+
expect(after.text).toBe('v2');
|
|
939
|
+
expect(after.color).toBe('#111111');
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test('move on an unknown id fails loud, writes nothing', () => {
|
|
943
|
+
seedCanvas('ui/MoveGhost.tsx', []);
|
|
944
|
+
const before = readFileSync(join(root, '.design', 'ui-moveghost.annotations.svg'), 'utf8');
|
|
945
|
+
const res = runAnnotate(
|
|
946
|
+
['ui/MoveGhost.tsx', '--root', root],
|
|
947
|
+
JSON.stringify({ ops: [{ op: 'move', id: 'ghost', x: 1, y: 1 }] })
|
|
948
|
+
);
|
|
949
|
+
expect(res.code).toBe(2);
|
|
950
|
+
expect(res.err).toMatch(/unknown id "ghost"/);
|
|
951
|
+
expect(readFileSync(join(root, '.design', 'ui-moveghost.annotations.svg'), 'utf8')).toBe(
|
|
952
|
+
before
|
|
953
|
+
);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test('move on an arrow fails loud (no single position)', () => {
|
|
957
|
+
const arrow: ArrowStroke = {
|
|
958
|
+
id: 'arr1',
|
|
959
|
+
tool: 'arrow',
|
|
960
|
+
color: '#000',
|
|
961
|
+
width: 2,
|
|
962
|
+
x1: 0,
|
|
963
|
+
y1: 0,
|
|
964
|
+
x2: 10,
|
|
965
|
+
y2: 10,
|
|
966
|
+
};
|
|
967
|
+
seedCanvas('ui/MoveArrow.tsx', [arrow]);
|
|
968
|
+
const res = runAnnotate(
|
|
969
|
+
['ui/MoveArrow.tsx', '--root', root],
|
|
970
|
+
JSON.stringify({ ops: [{ op: 'move', id: 'arr1', x: 5, y: 5 }] })
|
|
971
|
+
);
|
|
972
|
+
expect(res.code).toBe(2);
|
|
973
|
+
expect(res.err).toMatch(/no single position/);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test('move on anchored text fails loud (position derives from its host)', () => {
|
|
977
|
+
const anchored: TextStroke = {
|
|
978
|
+
id: 'anc1',
|
|
979
|
+
tool: 'text',
|
|
980
|
+
color: '#000',
|
|
981
|
+
fontSize: 14,
|
|
982
|
+
text: 'label',
|
|
983
|
+
anchorId: 'some-host',
|
|
984
|
+
};
|
|
985
|
+
seedCanvas('ui/MoveAnchored.tsx', [anchored]);
|
|
986
|
+
const res = runAnnotate(
|
|
987
|
+
['ui/MoveAnchored.tsx', '--root', root],
|
|
988
|
+
JSON.stringify({ ops: [{ op: 'move', id: 'anc1', x: 5, y: 5 }] })
|
|
989
|
+
);
|
|
990
|
+
expect(res.code).toBe(2);
|
|
991
|
+
expect(res.err).toMatch(/anchored text derives its position from its host/);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test('set-color / set-text on an image (no color/text field) fail loud', () => {
|
|
995
|
+
const img = {
|
|
996
|
+
id: 'img1',
|
|
997
|
+
tool: 'image',
|
|
998
|
+
x: 0,
|
|
999
|
+
y: 0,
|
|
1000
|
+
w: 100,
|
|
1001
|
+
h: 100,
|
|
1002
|
+
href: 'assets/deadbeef.png',
|
|
1003
|
+
} as unknown as Stroke;
|
|
1004
|
+
seedCanvas('ui/SetColorImage.tsx', [img]);
|
|
1005
|
+
const colorRes = runAnnotate(
|
|
1006
|
+
['ui/SetColorImage.tsx', '--root', root],
|
|
1007
|
+
JSON.stringify({ ops: [{ op: 'set-color', id: 'img1', color: '#fff' }] })
|
|
1008
|
+
);
|
|
1009
|
+
expect(colorRes.code).toBe(2);
|
|
1010
|
+
expect(colorRes.err).toMatch(/has no single color field/);
|
|
1011
|
+
|
|
1012
|
+
const textRes = runAnnotate(
|
|
1013
|
+
['ui/SetColorImage.tsx', '--root', root],
|
|
1014
|
+
JSON.stringify({ ops: [{ op: 'set-text', id: 'img1', text: 'x' }] })
|
|
1015
|
+
);
|
|
1016
|
+
expect(textRes.code).toBe(2);
|
|
1017
|
+
expect(textRes.err).toMatch(/has no text\/label field/);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Security regression (feature-whiteboard-ai-toolkit review, F-happydom-fetch):
|
|
1021
|
+
// ensureFullStrokes() in bin/annotate.mjs uses GlobalRegistrator.register()
|
|
1022
|
+
// (happy-dom's internals need the full scaffolding — a bare DOMParser patch
|
|
1023
|
+
// leaves querySelector's own error path broken, since it reaches through
|
|
1024
|
+
// `this.window`) then calls the documented `unregister()` inverse right
|
|
1025
|
+
// after the parse, so the later loopback-gated PUT never runs over
|
|
1026
|
+
// happy-dom's own fetch/URL polyfill. Run in a FRESH subprocess (not this
|
|
1027
|
+
// file's own process, which already has GlobalRegistrator registered for
|
|
1028
|
+
// the whole suite via beforeAll) to pin the contract cleanly: unregister()
|
|
1029
|
+
// restores the exact pre-register fetch reference, not just "a" fetch.
|
|
1030
|
+
test('GlobalRegistrator.register/unregister contract: fetch is restored to the exact pre-register reference', () => {
|
|
1031
|
+
const proc = Bun.spawnSync([
|
|
1032
|
+
'bun',
|
|
1033
|
+
'-e',
|
|
1034
|
+
`
|
|
1035
|
+
const { GlobalRegistrator } = await import('@happy-dom/global-registrator');
|
|
1036
|
+
const nativeFetch = globalThis.fetch;
|
|
1037
|
+
GlobalRegistrator.register();
|
|
1038
|
+
if (globalThis.fetch === nativeFetch) { console.log('FAIL: fetch unchanged after register'); process.exit(1); }
|
|
1039
|
+
await GlobalRegistrator.unregister();
|
|
1040
|
+
if (globalThis.fetch !== nativeFetch) { console.log('FAIL: fetch not restored after unregister'); process.exit(1); }
|
|
1041
|
+
console.log('OK');
|
|
1042
|
+
`,
|
|
1043
|
+
]);
|
|
1044
|
+
expect(new TextDecoder().decode(proc.stdout).trim()).toBe('OK');
|
|
1045
|
+
expect(proc.exitCode).toBe(0);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// Security regression (feature-whiteboard-ai-toolkit review, F-readtime-cap):
|
|
1049
|
+
// MAX_ANNOTATIONS_BYTES (1 MB) was only ever checked on the MERGED OUTPUT —
|
|
1050
|
+
// a file already at/near the cap on disk (peer-written per DDR-054, or
|
|
1051
|
+
// git-committed by anyone) would still be read in full and DOM-parsed on
|
|
1052
|
+
// every move/set-text/set-color. Now rejected at read time, before parse.
|
|
1053
|
+
test('a pre-existing annotations file over the byte cap is rejected before it reaches the DOM parser', () => {
|
|
1054
|
+
// One oversized sticky's text is enough to push the file over 1 MB without
|
|
1055
|
+
// needing thousands of strokes.
|
|
1056
|
+
const big: StickyStroke = {
|
|
1057
|
+
id: 'huge1',
|
|
1058
|
+
tool: 'sticky',
|
|
1059
|
+
color: '#fce8a6',
|
|
1060
|
+
x: 10,
|
|
1061
|
+
y: 10,
|
|
1062
|
+
w: 200,
|
|
1063
|
+
h: 100,
|
|
1064
|
+
text: 'x'.repeat(1024 * 1024 + 1),
|
|
1065
|
+
};
|
|
1066
|
+
seedCanvas('ui/HugeFile.tsx', [big]);
|
|
1067
|
+
const res = runAnnotate(
|
|
1068
|
+
['ui/HugeFile.tsx', '--root', root],
|
|
1069
|
+
JSON.stringify({ ops: [{ op: 'move', id: 'huge1', x: 20, y: 20 }] })
|
|
1070
|
+
);
|
|
1071
|
+
expect(res.code).toBe(2);
|
|
1072
|
+
expect(res.err).toMatch(/exceeds 1048576 bytes on disk/);
|
|
1073
|
+
});
|
|
1074
|
+
});
|