@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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// POST /_api/{delete-element,insert-element,insert-artboard,resize-artboard}
|
|
2
|
+
// round-trips (Stage I + D4, feature-element-editing-robustness). Boots a real
|
|
3
|
+
// server, serves a canvas (injects data-cd-id + _locator.json), then drives each
|
|
4
|
+
// structural edit through the HTTP layer and asserts: the source mutates on
|
|
5
|
+
// disk, a whole-file undo seq is returned + reverts through /_api/reorder-revert,
|
|
6
|
+
// a pre-op history snapshot lands, and a cross-origin POST is CSRF-rejected.
|
|
7
|
+
// Route unreachability from the canvas origin is proven in canvas-origin-gate.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
14
|
+
|
|
15
|
+
const CANVAS_SRC = `export default function List() {
|
|
16
|
+
return (
|
|
17
|
+
<DesignCanvas>
|
|
18
|
+
<DCArtboard id="home" label="Home" width={1440} height={1024}>
|
|
19
|
+
<div>A</div>
|
|
20
|
+
<div>B</div>
|
|
21
|
+
<div>C</div>
|
|
22
|
+
</DCArtboard>
|
|
23
|
+
</DesignCanvas>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/** div ids ordered by source line (== authored order A, B, C). */
|
|
29
|
+
async function divIdsByLine(main: string, designRoot: string): Promise<string[]> {
|
|
30
|
+
const r = await fetch(`${main}/.design/ui/List.tsx`, { signal: AbortSignal.timeout(2000) });
|
|
31
|
+
expect(r.status).toBe(200);
|
|
32
|
+
const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
|
|
33
|
+
const map = locator['ui/List'] as Record<string, { line: number; jsxPath: string[] }>;
|
|
34
|
+
return Object.entries(map)
|
|
35
|
+
.filter(([, e]) => e.jsxPath[e.jsxPath.length - 1] === 'div')
|
|
36
|
+
.sort((a, b) => a[1].line - b[1].line)
|
|
37
|
+
.map(([id]) => id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function letters(src: string): string[] {
|
|
41
|
+
return [...src.matchAll(/>([ABC])</g)].map((m) => m[1] as string);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function boot(
|
|
45
|
+
extraEnv?: Record<string, string>
|
|
46
|
+
): Promise<{ root: string; designRoot: string; main: string; proc: unknown }> {
|
|
47
|
+
const { root, designRoot } = makeSandbox();
|
|
48
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
49
|
+
writeFileSync(join(designRoot, 'ui', 'List.tsx'), CANVAS_SRC);
|
|
50
|
+
const port = nextPort();
|
|
51
|
+
const proc = await bootServer(root, port, extraEnv);
|
|
52
|
+
return { root, designRoot, main: `http://localhost:${port}`, proc };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('POST /_api/delete-element', () => {
|
|
56
|
+
test('removes an element on disk, returns a revert seq, snapshots, CSRF-guards', async () => {
|
|
57
|
+
const { designRoot, main, proc } = await boot();
|
|
58
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
59
|
+
try {
|
|
60
|
+
const [, bId] = await divIdsByLine(main, designRoot);
|
|
61
|
+
const res = await fetch(`${main}/_api/delete-element`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
body: JSON.stringify({ canvas: 'ui/List', id: bId }),
|
|
64
|
+
signal: AbortSignal.timeout(2000),
|
|
65
|
+
});
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
const json = (await res.json()) as { ok: boolean; seq: number };
|
|
68
|
+
expect(json.ok).toBe(true);
|
|
69
|
+
expect(typeof json.seq).toBe('number');
|
|
70
|
+
expect(letters(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'C']);
|
|
71
|
+
|
|
72
|
+
// pre-delete snapshot landed (rollback path).
|
|
73
|
+
const histDir = join(designRoot, '_history', 'ui-list');
|
|
74
|
+
expect(existsSync(histDir)).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Undo restores B.
|
|
77
|
+
const undo = await fetch(`${main}/_api/reorder-revert`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
|
|
80
|
+
signal: AbortSignal.timeout(2000),
|
|
81
|
+
});
|
|
82
|
+
expect(undo.status).toBe(200);
|
|
83
|
+
expect(letters(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'B', 'C']);
|
|
84
|
+
|
|
85
|
+
// Cross-origin POST is CSRF-rejected.
|
|
86
|
+
const forged = await fetch(`${main}/_api/delete-element`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { origin: 'http://evil.example' },
|
|
89
|
+
body: JSON.stringify({ canvas: 'ui/List', id: bId }),
|
|
90
|
+
signal: AbortSignal.timeout(2000),
|
|
91
|
+
});
|
|
92
|
+
expect(forged.status).toBe(403);
|
|
93
|
+
expect(letters(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'B', 'C']);
|
|
94
|
+
} finally {
|
|
95
|
+
await killProc(proc as never);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('POST /_api/insert-element', () => {
|
|
101
|
+
test('inserts a div after an anchor, returns its new id + revert seq', async () => {
|
|
102
|
+
const { designRoot, main, proc } = await boot();
|
|
103
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
104
|
+
try {
|
|
105
|
+
const [aId] = await divIdsByLine(main, designRoot);
|
|
106
|
+
const res = await fetch(`${main}/_api/insert-element`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: JSON.stringify({ canvas: 'ui/List', refId: aId, position: 'after', kind: 'div' }),
|
|
109
|
+
signal: AbortSignal.timeout(2000),
|
|
110
|
+
});
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
const json = (await res.json()) as { ok: boolean; newId: string | null; seq: number };
|
|
113
|
+
expect(json.ok).toBe(true);
|
|
114
|
+
expect(json.newId).toMatch(/^[0-9a-f]{8}$/);
|
|
115
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain("background: 'var(--bg-2)'");
|
|
116
|
+
|
|
117
|
+
// Undo removes the inserted element.
|
|
118
|
+
await fetch(`${main}/_api/reorder-revert`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
|
|
121
|
+
signal: AbortSignal.timeout(2000),
|
|
122
|
+
});
|
|
123
|
+
expect(readFileSync(canvasPath, 'utf8')).not.toContain("background: 'var(--bg-2)'");
|
|
124
|
+
|
|
125
|
+
// An image insert with no src is a 422 refusal (needs a contained asset).
|
|
126
|
+
const noSrc = await fetch(`${main}/_api/insert-element`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
body: JSON.stringify({ canvas: 'ui/List', refId: aId, position: 'after', kind: 'image' }),
|
|
129
|
+
signal: AbortSignal.timeout(2000),
|
|
130
|
+
});
|
|
131
|
+
expect(noSrc.status).toBe(422);
|
|
132
|
+
} finally {
|
|
133
|
+
await killProc(proc as never);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('POST /_api/duplicate-element', () => {
|
|
139
|
+
test('duplicates an element as the next sibling, returns a new id, undo removes it', async () => {
|
|
140
|
+
const { designRoot, main, proc } = await boot();
|
|
141
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
142
|
+
try {
|
|
143
|
+
const [aId] = await divIdsByLine(main, designRoot);
|
|
144
|
+
const res = await fetch(`${main}/_api/duplicate-element`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
body: JSON.stringify({ canvas: 'ui/List', id: aId }),
|
|
147
|
+
signal: AbortSignal.timeout(2000),
|
|
148
|
+
});
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
const json = (await res.json()) as { ok: boolean; newId: string | null; seq: number };
|
|
151
|
+
expect(json.ok).toBe(true);
|
|
152
|
+
expect(json.newId).toMatch(/^[0-9a-f]{8}$/);
|
|
153
|
+
// The first <div>A</div> is now duplicated → A, A, B, C.
|
|
154
|
+
expect(letters(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'A', 'B', 'C']);
|
|
155
|
+
|
|
156
|
+
// Undo removes the copy.
|
|
157
|
+
await fetch(`${main}/_api/reorder-revert`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
|
|
160
|
+
signal: AbortSignal.timeout(2000),
|
|
161
|
+
});
|
|
162
|
+
expect(letters(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'B', 'C']);
|
|
163
|
+
} finally {
|
|
164
|
+
await killProc(proc as never);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('POST /_api/insert-artboard + /_api/resize-artboard', () => {
|
|
170
|
+
test('inserts an empty artboard, then resizes it via numeric props', async () => {
|
|
171
|
+
const { designRoot, main, proc } = await boot();
|
|
172
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
173
|
+
try {
|
|
174
|
+
// Warm the pipeline / locator.
|
|
175
|
+
await divIdsByLine(main, designRoot);
|
|
176
|
+
|
|
177
|
+
const ins = await fetch(`${main}/_api/insert-artboard`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
canvas: 'ui/List',
|
|
181
|
+
id: 'mobile',
|
|
182
|
+
label: 'Mobile',
|
|
183
|
+
width: 390,
|
|
184
|
+
height: 844,
|
|
185
|
+
}),
|
|
186
|
+
signal: AbortSignal.timeout(2000),
|
|
187
|
+
});
|
|
188
|
+
expect(ins.status).toBe(200);
|
|
189
|
+
const insJson = (await ins.json()) as { ok: boolean; artboardId: string; seq: number };
|
|
190
|
+
expect(insJson.artboardId).toBe('mobile');
|
|
191
|
+
let src = readFileSync(canvasPath, 'utf8');
|
|
192
|
+
expect(src).toContain('id="mobile"');
|
|
193
|
+
expect(src).toContain('width={390} height={844}');
|
|
194
|
+
|
|
195
|
+
// Resize the new artboard (numeric props, never string literals).
|
|
196
|
+
const rz = await fetch(`${main}/_api/resize-artboard`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
body: JSON.stringify({ canvas: 'ui/List', artboardId: 'mobile', width: 420, height: 900 }),
|
|
199
|
+
signal: AbortSignal.timeout(2000),
|
|
200
|
+
});
|
|
201
|
+
expect(rz.status).toBe(200);
|
|
202
|
+
const rzJson = (await rz.json()) as { ok: boolean; seq: number };
|
|
203
|
+
src = readFileSync(canvasPath, 'utf8');
|
|
204
|
+
expect(src).toContain('width={420} height={900}');
|
|
205
|
+
expect(src).not.toContain('width="420"');
|
|
206
|
+
|
|
207
|
+
// Undo the resize → back to 390×844.
|
|
208
|
+
await fetch(`${main}/_api/reorder-revert`, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: rzJson.seq, dir: 'undo' }),
|
|
211
|
+
signal: AbortSignal.timeout(2000),
|
|
212
|
+
});
|
|
213
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain('width={390} height={844}');
|
|
214
|
+
|
|
215
|
+
// A duplicate-id insert is a 422 refusal.
|
|
216
|
+
const dup = await fetch(`${main}/_api/insert-artboard`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
canvas: 'ui/List',
|
|
220
|
+
id: 'home',
|
|
221
|
+
label: 'Dup',
|
|
222
|
+
width: 390,
|
|
223
|
+
height: 844,
|
|
224
|
+
}),
|
|
225
|
+
signal: AbortSignal.timeout(2000),
|
|
226
|
+
});
|
|
227
|
+
expect(dup.status).toBe(422);
|
|
228
|
+
|
|
229
|
+
// Cross-origin resize is CSRF-rejected.
|
|
230
|
+
const forged = await fetch(`${main}/_api/resize-artboard`, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { origin: 'http://evil.example' },
|
|
233
|
+
body: JSON.stringify({ canvas: 'ui/List', artboardId: 'home', width: 100, height: 100 }),
|
|
234
|
+
signal: AbortSignal.timeout(2000),
|
|
235
|
+
});
|
|
236
|
+
expect(forged.status).toBe(403);
|
|
237
|
+
} finally {
|
|
238
|
+
await killProc(proc as never);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('POST /_api/delete-artboard', () => {
|
|
244
|
+
test('deletes an artboard by id prop, refuses the last one, undo restores', async () => {
|
|
245
|
+
const { designRoot, main, proc } = await boot();
|
|
246
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
247
|
+
try {
|
|
248
|
+
await divIdsByLine(main, designRoot); // warm pipeline
|
|
249
|
+
|
|
250
|
+
// Deleting the only artboard is refused (a canvas needs ≥1).
|
|
251
|
+
const last = await fetch(`${main}/_api/delete-artboard`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
body: JSON.stringify({ canvas: 'ui/List', artboardId: 'home' }),
|
|
254
|
+
signal: AbortSignal.timeout(2000),
|
|
255
|
+
});
|
|
256
|
+
expect(last.status).toBe(422);
|
|
257
|
+
|
|
258
|
+
// Add a second artboard, then delete it.
|
|
259
|
+
await fetch(`${main}/_api/insert-artboard`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
canvas: 'ui/List',
|
|
263
|
+
id: 'mobile',
|
|
264
|
+
label: 'M',
|
|
265
|
+
width: 390,
|
|
266
|
+
height: 844,
|
|
267
|
+
}),
|
|
268
|
+
signal: AbortSignal.timeout(2000),
|
|
269
|
+
});
|
|
270
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain('id="mobile"');
|
|
271
|
+
|
|
272
|
+
const del = await fetch(`${main}/_api/delete-artboard`, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
body: JSON.stringify({ canvas: 'ui/List', artboardId: 'mobile' }),
|
|
275
|
+
signal: AbortSignal.timeout(2000),
|
|
276
|
+
});
|
|
277
|
+
expect(del.status).toBe(200);
|
|
278
|
+
const delJson = (await del.json()) as { ok: boolean; seq: number };
|
|
279
|
+
expect(readFileSync(canvasPath, 'utf8')).not.toContain('id="mobile"');
|
|
280
|
+
|
|
281
|
+
// Undo restores the deleted artboard.
|
|
282
|
+
await fetch(`${main}/_api/reorder-revert`, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: delJson.seq, dir: 'undo' }),
|
|
285
|
+
signal: AbortSignal.timeout(2000),
|
|
286
|
+
});
|
|
287
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain('id="mobile"');
|
|
288
|
+
|
|
289
|
+
// Cross-origin delete is CSRF-rejected.
|
|
290
|
+
const forged = await fetch(`${main}/_api/delete-artboard`, {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: { origin: 'http://evil.example' },
|
|
293
|
+
body: JSON.stringify({ canvas: 'ui/List', artboardId: 'mobile' }),
|
|
294
|
+
signal: AbortSignal.timeout(2000),
|
|
295
|
+
});
|
|
296
|
+
expect(forged.status).toBe(403);
|
|
297
|
+
} finally {
|
|
298
|
+
await killProc(proc as never);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// G3 security (DDR-152) — the structural-write disk-fill / silent-shred defenses
|
|
304
|
+
// the adversarial review required: a per-api token bucket rate-caps the verbs,
|
|
305
|
+
// and a source-size ceiling refuses a GROWTH op past MAX_CANVAS_SOURCE (a delete
|
|
306
|
+
// / shrink always passes). Booted with tiny caps so the bounds trip immediately.
|
|
307
|
+
describe('structural-write security caps', () => {
|
|
308
|
+
test('rate-caps a scripted burst of structural verbs (429 once the bucket is spent)', async () => {
|
|
309
|
+
const { designRoot, main, proc } = await boot({ MAUDE_STRUCTURAL_BURST: '2' });
|
|
310
|
+
try {
|
|
311
|
+
await divIdsByLine(main, designRoot); // warm pipeline/locator
|
|
312
|
+
const statuses: number[] = [];
|
|
313
|
+
for (let i = 0; i < 6; i++) {
|
|
314
|
+
const res = await fetch(`${main}/_api/insert-artboard`, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
body: JSON.stringify({
|
|
317
|
+
canvas: 'ui/List',
|
|
318
|
+
id: `ab${i}`,
|
|
319
|
+
label: `AB${i}`,
|
|
320
|
+
width: 390,
|
|
321
|
+
height: 844,
|
|
322
|
+
}),
|
|
323
|
+
signal: AbortSignal.timeout(2000),
|
|
324
|
+
});
|
|
325
|
+
statuses.push(res.status);
|
|
326
|
+
}
|
|
327
|
+
// First op always passes; the burst (2) is exhausted well before 6 rapid
|
|
328
|
+
// ops, so at least one 429 appears and successes stay bounded (timing-safe).
|
|
329
|
+
expect(statuses[0]).toBe(200);
|
|
330
|
+
expect(statuses).toContain(429);
|
|
331
|
+
expect(statuses.filter((s) => s === 200).length).toBeLessThanOrEqual(3);
|
|
332
|
+
} finally {
|
|
333
|
+
await killProc(proc as never);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('refuses a growth op past the source-size ceiling but still allows a delete', async () => {
|
|
338
|
+
// Ceiling 100 bytes < the base canvas (~250B) → any insert is refused, but a
|
|
339
|
+
// delete (which shrinks the file) is unaffected.
|
|
340
|
+
const { designRoot, main, proc } = await boot({ MAUDE_MAX_CANVAS_SOURCE: '100' });
|
|
341
|
+
try {
|
|
342
|
+
const [aId, bId] = await divIdsByLine(main, designRoot);
|
|
343
|
+
const ins = await fetch(`${main}/_api/insert-element`, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
body: JSON.stringify({ canvas: 'ui/List', refId: aId, position: 'after', kind: 'div' }),
|
|
346
|
+
signal: AbortSignal.timeout(2000),
|
|
347
|
+
});
|
|
348
|
+
expect(ins.status).toBe(413);
|
|
349
|
+
const del = await fetch(`${main}/_api/delete-element`, {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
body: JSON.stringify({ canvas: 'ui/List', id: bId }),
|
|
352
|
+
signal: AbortSignal.timeout(2000),
|
|
353
|
+
});
|
|
354
|
+
expect(del.status).toBe(200);
|
|
355
|
+
expect(letters(readFileSync(join(designRoot, 'ui', 'List.tsx'), 'utf8'))).toEqual(['A', 'C']);
|
|
356
|
+
} finally {
|
|
357
|
+
await killProc(proc as never);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// canvas-edit.ts — Stage I (feature-element-editing-robustness). Structural
|
|
2
|
+
// element edits: delete / insert / new-artboard + free-hand artboard resize
|
|
3
|
+
// (D4). Round-trips ids through transpileCanvasSource the same way
|
|
4
|
+
// canvas-edit.test.ts does, so the positional data-cd-id arithmetic stays in
|
|
5
|
+
// lockstep with the pipeline.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
applyDeleteElement,
|
|
11
|
+
applyInsertArtboard,
|
|
12
|
+
applyInsertElement,
|
|
13
|
+
applyResizeArtboard,
|
|
14
|
+
CanvasEditError,
|
|
15
|
+
} from '../canvas-edit.ts';
|
|
16
|
+
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
17
|
+
|
|
18
|
+
const CANVAS = '/abs/Canvas.tsx';
|
|
19
|
+
|
|
20
|
+
/** Map data-cd-id by element-type name (first occurrence), like canvas-edit.test.ts. */
|
|
21
|
+
function idsOf(source: string): Record<string, string> {
|
|
22
|
+
const { withIds } = transpileCanvasSource(CANVAS, source);
|
|
23
|
+
const out: Record<string, string> = {};
|
|
24
|
+
for (const m of withIds.matchAll(/<(\w+)([^>]*?)data-cd-id="([0-9a-f]{8})"/g)) {
|
|
25
|
+
if (!out[m[1] as string]) out[m[1] as string] = m[3] as string;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse-clean assertion — a structural edit must never emit invalid source. */
|
|
31
|
+
function parses(source: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
transpileCanvasSource(CANVAS, source);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('canvas-edit / applyDeleteElement', () => {
|
|
41
|
+
test('removes the target element and leaves valid source', () => {
|
|
42
|
+
const src = [
|
|
43
|
+
'function Demo() {',
|
|
44
|
+
' return (',
|
|
45
|
+
' <section>',
|
|
46
|
+
' <button>Keep</button>',
|
|
47
|
+
' <span>Drop</span>',
|
|
48
|
+
' </section>',
|
|
49
|
+
' );',
|
|
50
|
+
'}',
|
|
51
|
+
].join('\n');
|
|
52
|
+
const id = idsOf(src).span as string;
|
|
53
|
+
const out = applyDeleteElement(CANVAS, src, id);
|
|
54
|
+
expect(out.source).not.toContain('<span>Drop</span>');
|
|
55
|
+
expect(out.source).toContain('<button>Keep</button>');
|
|
56
|
+
expect(parses(out.source)).toBe(true);
|
|
57
|
+
// No blank line left behind (framed span removed the leading newline+indent).
|
|
58
|
+
expect(out.source).not.toMatch(/\n\s*\n\s*<\/section>/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('reparse gate rejects a delete that would break JSX (sole parenthesized return)', () => {
|
|
62
|
+
// Deleting the only element of `return ( … )` leaves `return ();` — invalid.
|
|
63
|
+
const src = 'function Demo() { return (<span>x</span>); }';
|
|
64
|
+
const id = idsOf(src).span as string;
|
|
65
|
+
expect(() => applyDeleteElement(CANVAS, src, id)).toThrow(CanvasEditError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('unknown id throws', () => {
|
|
69
|
+
const src = 'function Demo() { return <div>x</div>; }';
|
|
70
|
+
expect(() => applyDeleteElement(CANVAS, src, 'deadbeef')).toThrow(CanvasEditError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('shared-component instance delete targets the specific usage', () => {
|
|
74
|
+
const src = [
|
|
75
|
+
'function Card() { return <article>card</article>; }',
|
|
76
|
+
'function Demo() {',
|
|
77
|
+
' return (',
|
|
78
|
+
' <section>',
|
|
79
|
+
' <Card />',
|
|
80
|
+
' <Card />',
|
|
81
|
+
' <Card />',
|
|
82
|
+
' </section>',
|
|
83
|
+
' );',
|
|
84
|
+
'}',
|
|
85
|
+
].join('\n');
|
|
86
|
+
// The inner <article> id is shared across all three <Card/> usages.
|
|
87
|
+
const innerId = idsOf(src).article as string;
|
|
88
|
+
const out = applyDeleteElement(CANVAS, src, innerId, 1); // delete the 2nd usage
|
|
89
|
+
// Exactly one <Card /> usage removed, two remain.
|
|
90
|
+
expect(out.source.match(/<Card \/>/g)?.length).toBe(2);
|
|
91
|
+
// The component definition is untouched.
|
|
92
|
+
expect(out.source).toContain('function Card()');
|
|
93
|
+
expect(parses(out.source)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('canvas-edit / applyInsertElement', () => {
|
|
98
|
+
const base = [
|
|
99
|
+
'function Demo() {',
|
|
100
|
+
' return (',
|
|
101
|
+
' <section>',
|
|
102
|
+
' <button>Anchor</button>',
|
|
103
|
+
' </section>',
|
|
104
|
+
' );',
|
|
105
|
+
'}',
|
|
106
|
+
].join('\n');
|
|
107
|
+
|
|
108
|
+
test('inserts a div AFTER the anchor and returns its new id', () => {
|
|
109
|
+
const id = idsOf(base).button as string;
|
|
110
|
+
const out = applyInsertElement(CANVAS, base, id, 'after', 'div');
|
|
111
|
+
expect(out.source).toContain("background: 'var(--bg-2)'");
|
|
112
|
+
expect(parses(out.source)).toBe(true);
|
|
113
|
+
expect(out.newId).toMatch(/^[0-9a-f]{8}$/);
|
|
114
|
+
// The recomputed id matches the pipeline-stamped id of the new <div>.
|
|
115
|
+
expect(idsOf(out.source).div).toBe(out.newId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('inserts a text node BEFORE the anchor', () => {
|
|
119
|
+
const id = idsOf(base).button as string;
|
|
120
|
+
const out = applyInsertElement(CANVAS, base, id, 'before', 'text');
|
|
121
|
+
expect(out.source).toContain('<p style={{ margin: 0 }}>Text</p>');
|
|
122
|
+
// Order: the <p> precedes the <button> in source.
|
|
123
|
+
expect(out.source.indexOf('<p ')).toBeLessThan(out.source.indexOf('<button'));
|
|
124
|
+
expect(parses(out.source)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('inserts INSIDE the anchor (inside-end)', () => {
|
|
128
|
+
const id = idsOf(base).section as string;
|
|
129
|
+
const out = applyInsertElement(CANVAS, base, id, 'inside-end', 'div');
|
|
130
|
+
// The new div is nested within <section>…</section>.
|
|
131
|
+
expect(out.source).toMatch(/<section>[\s\S]*var\(--bg-2\)[\s\S]*<\/section>/);
|
|
132
|
+
expect(parses(out.source)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('image insert requires a contained asset src', () => {
|
|
136
|
+
const id = idsOf(base).button as string;
|
|
137
|
+
expect(() => applyInsertElement(CANVAS, base, id, 'after', 'image')).toThrow(CanvasEditError);
|
|
138
|
+
expect(() =>
|
|
139
|
+
applyInsertElement(CANVAS, base, id, 'after', 'image', { src: '../etc/passwd' })
|
|
140
|
+
).toThrow(CanvasEditError);
|
|
141
|
+
const ok = applyInsertElement(CANVAS, base, id, 'after', 'image', {
|
|
142
|
+
src: 'assets/ab12cd34.png',
|
|
143
|
+
});
|
|
144
|
+
expect(ok.source).toContain('src="assets/ab12cd34.png"');
|
|
145
|
+
expect(ok.source).toContain("objectFit: 'cover'");
|
|
146
|
+
expect(parses(ok.source)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('refuses to nest inside a self-closing target', () => {
|
|
150
|
+
const src = 'function Demo() { return <section><img src="assets/a.png" /></section>; }';
|
|
151
|
+
const id = idsOf(src).img as string;
|
|
152
|
+
expect(() => applyInsertElement(CANVAS, src, id, 'inside-end', 'div')).toThrow(CanvasEditError);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('canvas-edit / applyResizeArtboard (D4)', () => {
|
|
157
|
+
const canvas = [
|
|
158
|
+
'export default function Demo() {',
|
|
159
|
+
' return (',
|
|
160
|
+
' <DesignCanvas>',
|
|
161
|
+
' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
|
|
162
|
+
' <div>content</div>',
|
|
163
|
+
' </DCArtboard>',
|
|
164
|
+
' </DesignCanvas>',
|
|
165
|
+
' );',
|
|
166
|
+
'}',
|
|
167
|
+
].join('\n');
|
|
168
|
+
|
|
169
|
+
test('rewrites width + height numeric props (not string literals)', () => {
|
|
170
|
+
const out = applyResizeArtboard(CANVAS, canvas, 'home', 1200, 900);
|
|
171
|
+
expect(out.source).toContain('width={1200}');
|
|
172
|
+
expect(out.source).toContain('height={900}');
|
|
173
|
+
expect(out.source).not.toContain('width="1200"'); // never a string literal
|
|
174
|
+
expect(parses(out.source)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('rounds + clamps and can change one axis only', () => {
|
|
178
|
+
const out = applyResizeArtboard(CANVAS, canvas, 'home', 800.7, undefined);
|
|
179
|
+
expect(out.source).toContain('width={801}');
|
|
180
|
+
expect(out.source).toContain('height={1024}'); // untouched
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('unknown artboard id throws', () => {
|
|
184
|
+
expect(() => applyResizeArtboard(CANVAS, canvas, 'nope', 100, 100)).toThrow(CanvasEditError);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('canvas-edit / applyInsertArtboard (I4)', () => {
|
|
189
|
+
const canvas = [
|
|
190
|
+
'export default function Demo() {',
|
|
191
|
+
' return (',
|
|
192
|
+
' <DesignCanvas>',
|
|
193
|
+
' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
|
|
194
|
+
' <div>content</div>',
|
|
195
|
+
' </DCArtboard>',
|
|
196
|
+
' </DesignCanvas>',
|
|
197
|
+
' );',
|
|
198
|
+
'}',
|
|
199
|
+
].join('\n');
|
|
200
|
+
|
|
201
|
+
test('appends an empty artboard after the last one', () => {
|
|
202
|
+
const out = applyInsertArtboard(CANVAS, canvas, {
|
|
203
|
+
id: 'mobile',
|
|
204
|
+
label: 'Mobile',
|
|
205
|
+
width: 390,
|
|
206
|
+
height: 844,
|
|
207
|
+
});
|
|
208
|
+
expect(out.artboardId).toBe('mobile');
|
|
209
|
+
expect(out.source).toContain(
|
|
210
|
+
'<DCArtboard id="mobile" label="Mobile" width={390} height={844}></DCArtboard>'
|
|
211
|
+
);
|
|
212
|
+
// It lands AFTER the existing artboard.
|
|
213
|
+
expect(out.source.indexOf('id="mobile"')).toBeGreaterThan(out.source.indexOf('id="home"'));
|
|
214
|
+
expect(parses(out.source)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('rejects a duplicate artboard id', () => {
|
|
218
|
+
expect(() =>
|
|
219
|
+
applyInsertArtboard(CANVAS, canvas, { id: 'home', label: 'Dup', width: 390, height: 844 })
|
|
220
|
+
).toThrow(CanvasEditError);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('rejects an invalid artboard id (injection guard)', () => {
|
|
224
|
+
expect(() =>
|
|
225
|
+
applyInsertArtboard(CANVAS, canvas, {
|
|
226
|
+
id: 'a" onload="x',
|
|
227
|
+
label: 'X',
|
|
228
|
+
width: 390,
|
|
229
|
+
height: 844,
|
|
230
|
+
})
|
|
231
|
+
).toThrow(CanvasEditError);
|
|
232
|
+
});
|
|
233
|
+
});
|