@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
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
computeAlign,
|
|
7
|
+
computeDistribute,
|
|
8
|
+
computePairGap,
|
|
9
|
+
computeTidyGrid,
|
|
10
|
+
detectEqualSpacing,
|
|
11
|
+
type KeyedRect,
|
|
12
|
+
} from '../equal-spacing-detector.ts';
|
|
6
13
|
import type { Rect } from '../use-snap-guides.tsx';
|
|
7
14
|
|
|
8
15
|
const r = (x: number, y: number, w = 100, h = 60): Rect => ({ x, y, w, h });
|
|
16
|
+
const kr = (key: string, x: number, y: number, w = 100, h = 60): KeyedRect => ({
|
|
17
|
+
key,
|
|
18
|
+
x,
|
|
19
|
+
y,
|
|
20
|
+
w,
|
|
21
|
+
h,
|
|
22
|
+
});
|
|
9
23
|
|
|
10
24
|
describe('detectEqualSpacing / preconditions', () => {
|
|
11
25
|
test('fewer than 3 rects → null', () => {
|
|
@@ -91,3 +105,153 @@ describe('detectEqualSpacing / 4+ rects', () => {
|
|
|
91
105
|
expect(out).toBeNull();
|
|
92
106
|
});
|
|
93
107
|
});
|
|
108
|
+
|
|
109
|
+
// Task L7 — Alt-hover measurement pairwise gap.
|
|
110
|
+
|
|
111
|
+
describe('computePairGap', () => {
|
|
112
|
+
test('measures the x-axis gap regardless of argument order', () => {
|
|
113
|
+
const a = r(0, 0, 100, 50);
|
|
114
|
+
const b = r(150, 20, 80, 50);
|
|
115
|
+
const fwd = computePairGap(a, b, 'x');
|
|
116
|
+
const rev = computePairGap(b, a, 'x');
|
|
117
|
+
expect(fwd).toEqual({ axis: 'x', gap: 50, from: 100, cross: 35 });
|
|
118
|
+
expect(rev).toEqual(fwd);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('measures the y-axis gap', () => {
|
|
122
|
+
const a = r(0, 0, 50, 60);
|
|
123
|
+
const b = r(10, 150, 50, 60);
|
|
124
|
+
const out = computePairGap(a, b, 'y');
|
|
125
|
+
expect(out).toEqual({ axis: 'y', gap: 90, from: 60, cross: 30 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('overlapping on the axis → null (no gap to show)', () => {
|
|
129
|
+
const a = r(0, 0, 100, 50);
|
|
130
|
+
const b = r(50, 0, 100, 50); // overlaps a on x
|
|
131
|
+
expect(computePairGap(a, b, 'x')).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('touching (gap === 0) → null', () => {
|
|
135
|
+
const a = r(0, 0, 100, 50);
|
|
136
|
+
const b = r(100, 0, 100, 50);
|
|
137
|
+
expect(computePairGap(a, b, 'x')).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Task L5 — distribute / align / tidy-up pure math.
|
|
142
|
+
|
|
143
|
+
describe('computeAlign', () => {
|
|
144
|
+
test('fewer than 2 rects → []', () => {
|
|
145
|
+
expect(computeAlign([], 'left')).toEqual([]);
|
|
146
|
+
expect(computeAlign([kr('a', 0, 0)], 'left')).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('left/right/top/bottom hold the union-bbox edge', () => {
|
|
150
|
+
const rects = [kr('a', 0, 0, 100, 50), kr('b', 40, 100, 60, 50), kr('c', 200, 30, 100, 50)];
|
|
151
|
+
const left = computeAlign(rects, 'left');
|
|
152
|
+
// union xMin = 0 → 'a' already at x=0 is a no-op (skipped); b/c move.
|
|
153
|
+
expect(left).toEqual(
|
|
154
|
+
expect.arrayContaining([
|
|
155
|
+
{ key: 'b', x: 0, y: 100 },
|
|
156
|
+
{ key: 'c', x: 0, y: 30 },
|
|
157
|
+
])
|
|
158
|
+
);
|
|
159
|
+
expect(left.find((m) => m.key === 'a')).toBeUndefined();
|
|
160
|
+
|
|
161
|
+
const right = computeAlign(rects, 'right');
|
|
162
|
+
// union xMax = 300 (c: 200+100)
|
|
163
|
+
expect(right.find((m) => m.key === 'a')).toEqual({ key: 'a', x: 200, y: 0 });
|
|
164
|
+
expect(right.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 240, y: 100 });
|
|
165
|
+
expect(right.find((m) => m.key === 'c')).toBeUndefined(); // already at right edge
|
|
166
|
+
|
|
167
|
+
const top = computeAlign(rects, 'top');
|
|
168
|
+
expect(top.find((m) => m.key === 'a')).toBeUndefined(); // already yMin=0
|
|
169
|
+
expect(top.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 40, y: 0 });
|
|
170
|
+
expect(top.find((m) => m.key === 'c')).toEqual({ key: 'c', x: 200, y: 0 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('center-x / center-y align to the union-bbox midpoint', () => {
|
|
174
|
+
const rects = [kr('a', 0, 0, 100, 100), kr('b', 300, 0, 100, 100)];
|
|
175
|
+
// union bbox x: 0..400, cx=200
|
|
176
|
+
const centerX = computeAlign(rects, 'center-x');
|
|
177
|
+
expect(centerX).toEqual(
|
|
178
|
+
expect.arrayContaining([
|
|
179
|
+
{ key: 'a', x: 150, y: 0 },
|
|
180
|
+
{ key: 'b', x: 150, y: 0 },
|
|
181
|
+
])
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('no-op rects (already aligned) are omitted from the result', () => {
|
|
186
|
+
const rects = [kr('a', 0, 0), kr('b', 0, 50)];
|
|
187
|
+
expect(computeAlign(rects, 'left')).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('computeDistribute', () => {
|
|
192
|
+
test('fewer than 3 rects → []', () => {
|
|
193
|
+
expect(computeDistribute([kr('a', 0, 0), kr('b', 200, 0)], 'x')).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('holds first + last, spaces the middle evenly on x', () => {
|
|
197
|
+
// 0..100, then a bunched pair at 110/130, then 300..400 — total span
|
|
198
|
+
// first.x(0) to last.x+w(400) = 400, minus side-lengths(100+80+90+100=370) = 30 → gap 10
|
|
199
|
+
const rects = [
|
|
200
|
+
kr('a', 0, 0, 100, 50),
|
|
201
|
+
kr('b', 110, 0, 80, 50),
|
|
202
|
+
kr('c', 130, 0, 90, 50),
|
|
203
|
+
kr('d', 300, 0, 100, 50),
|
|
204
|
+
];
|
|
205
|
+
const out = computeDistribute(rects, 'x');
|
|
206
|
+
expect(out).toHaveLength(2); // only the 2 middle rects move
|
|
207
|
+
expect(out.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 110, y: 0 });
|
|
208
|
+
expect(out.find((m) => m.key === 'c')).toEqual({ key: 'c', x: 200, y: 0 });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('already-equal gaps still returns the (unchanged) middle positions', () => {
|
|
212
|
+
const rects = [kr('a', 0, 0), kr('b', 120, 0), kr('c', 240, 0)];
|
|
213
|
+
const out = computeDistribute(rects, 'x');
|
|
214
|
+
expect(out).toEqual([{ key: 'b', x: 120, y: 0 }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('distributes on y', () => {
|
|
218
|
+
// y: 0..60, 90..150, 300..360 — total sides 180, span 360-180=180, gap 90
|
|
219
|
+
const rects = [kr('a', 0, 0, 50, 60), kr('b', 0, 90, 50, 60), kr('c', 0, 300, 50, 60)];
|
|
220
|
+
const out = computeDistribute(rects, 'y');
|
|
221
|
+
expect(out).toHaveLength(1);
|
|
222
|
+
expect(out[0]).toEqual({ key: 'b', x: 0, y: 150 });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('computeTidyGrid', () => {
|
|
227
|
+
test('fewer than 2 rects → []', () => {
|
|
228
|
+
expect(computeTidyGrid([kr('a', 5, 5)])).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('4 rects → 2×2 grid, reading-order sorted, from the union-bbox origin', () => {
|
|
232
|
+
// scattered, deliberately out of reading order
|
|
233
|
+
const rects = [
|
|
234
|
+
kr('br', 500, 500, 50, 50),
|
|
235
|
+
kr('tl', 0, 0, 50, 50),
|
|
236
|
+
kr('tr', 400, 10, 50, 50),
|
|
237
|
+
kr('bl', 20, 490, 50, 50),
|
|
238
|
+
];
|
|
239
|
+
const out = computeTidyGrid(rects, { gap: 10 });
|
|
240
|
+
expect(out).toHaveLength(4);
|
|
241
|
+
const byKey = Object.fromEntries(out.map((m) => [m.key, m]));
|
|
242
|
+
// reading order (y then x): tl(0,0) → tr(400,10) → bl(20,490) → br(500,500)
|
|
243
|
+
// columns = round(sqrt(4)) = 2 → row0: tl,tr · row1: bl,br
|
|
244
|
+
expect(byKey.tl).toEqual({ key: 'tl', x: 0, y: 0 });
|
|
245
|
+
expect(byKey.tr).toEqual({ key: 'tr', x: 60, y: 0 }); // col0 width 50 + gap 10
|
|
246
|
+
expect(byKey.bl).toEqual({ key: 'bl', x: 0, y: 60 });
|
|
247
|
+
expect(byKey.br).toEqual({ key: 'br', x: 60, y: 60 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('honors an explicit column count', () => {
|
|
251
|
+
const rects = [kr('a', 0, 0, 40, 20), kr('b', 0, 0, 40, 20), kr('c', 0, 0, 40, 20)];
|
|
252
|
+
const out = computeTidyGrid(rects, { gap: 5, columns: 1 });
|
|
253
|
+
// 1 column → stacked vertically
|
|
254
|
+
expect(out.map((m) => m.x)).toEqual([0, 0, 0]);
|
|
255
|
+
expect(out.map((m) => m.y)).toEqual([0, 25, 50]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Regression tests for exporters/_runtime.ts — the shared shim-path + runtime
|
|
2
|
+
// resolver behind the desktop-export fix (RCA issue-desktop-export-failures).
|
|
3
|
+
//
|
|
4
|
+
// Guards the two defects the render exporters shipped with:
|
|
5
|
+
// 1. shim paths must derive from DEV_SERVER_ROOT (paths.ts), NEVER a virtual
|
|
6
|
+
// `/$bunfs/root` (DDR-045).
|
|
7
|
+
// 2. the JS runtime must be resolved (not a hardcoded `'node'`), with an
|
|
8
|
+
// actionable throw when none is on PATH.
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
import { EXPORT_SHIM_DIR, exportShimPath, resolveExportRuntime } from '../../exporters/_runtime.ts';
|
|
12
|
+
import { DEV_SERVER_ROOT } from '../../paths.ts';
|
|
13
|
+
|
|
14
|
+
const ORIGINAL_PATH = process.env.PATH;
|
|
15
|
+
const ORIGINAL_RUNTIME = process.env.MAUDE_EXPORT_RUNTIME;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
// Restore any env mutated by a test (each bun:test file is its own process,
|
|
19
|
+
// but tests within it share env — keep them isolated).
|
|
20
|
+
if (ORIGINAL_PATH === undefined) delete process.env.PATH;
|
|
21
|
+
else process.env.PATH = ORIGINAL_PATH;
|
|
22
|
+
if (ORIGINAL_RUNTIME === undefined) delete process.env.MAUDE_EXPORT_RUNTIME;
|
|
23
|
+
else process.env.MAUDE_EXPORT_RUNTIME = ORIGINAL_RUNTIME;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('exporters/_runtime — shim paths (DDR-045)', () => {
|
|
27
|
+
test('EXPORT_SHIM_DIR is <DEV_SERVER_ROOT>/bin and never virtual /$bunfs', () => {
|
|
28
|
+
expect(EXPORT_SHIM_DIR).toBe(`${DEV_SERVER_ROOT}/bin`);
|
|
29
|
+
expect(EXPORT_SHIM_DIR).not.toContain('$bunfs');
|
|
30
|
+
expect(EXPORT_SHIM_DIR).not.toContain('~BUN');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('exportShimPath resolves a real disk path under the shim dir', () => {
|
|
34
|
+
const p = exportShimPath('_png-playwright.mjs');
|
|
35
|
+
expect(p).toBe(`${DEV_SERVER_ROOT}/bin/_png-playwright.mjs`);
|
|
36
|
+
expect(p).not.toContain('$bunfs');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('exporters/_runtime — runtime ladder', () => {
|
|
41
|
+
test('honors the MAUDE_EXPORT_RUNTIME override', () => {
|
|
42
|
+
process.env.MAUDE_EXPORT_RUNTIME = '/opt/custom/bin/node';
|
|
43
|
+
expect(resolveExportRuntime()).toBe('/opt/custom/bin/node');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('resolves an on-PATH runtime when no override is set', () => {
|
|
47
|
+
delete process.env.MAUDE_EXPORT_RUNTIME;
|
|
48
|
+
// The test host has bun (and usually node) on PATH → a truthy absolute path.
|
|
49
|
+
const runtime = resolveExportRuntime();
|
|
50
|
+
expect(runtime).toBeTruthy();
|
|
51
|
+
expect(runtime).not.toBe('node'); // resolved to an absolute path, not the bare name
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('throws an actionable error when no node/bun runtime is reachable', () => {
|
|
55
|
+
delete process.env.MAUDE_EXPORT_RUNTIME;
|
|
56
|
+
process.env.PATH = '/nonexistent-maude-export-runtime-probe';
|
|
57
|
+
expect(() => resolveExportRuntime()).toThrow(/render export needs a JS runtime/i);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// file-lock.test.ts — DDR-150 P2. The cross-process advisory lock that closes
|
|
2
|
+
// the race the in-process mutex couldn't see: the HTTP server, the /design:edit
|
|
3
|
+
// CLI, and the HMR watcher all editing the same comp file. Verifies mutual
|
|
4
|
+
// exclusion across "processes" (concurrent acquirers), stale-lock stealing (a
|
|
5
|
+
// crashed holder), and that an edit through the integrated withLock cleans up.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { utimes } from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
import { acquireFileLock, editAttribute, lockPathFor } from '../canvas-edit.ts';
|
|
11
|
+
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
12
|
+
|
|
13
|
+
const tmp = (tag: string) => `/tmp/maude-lock-${tag}-${Math.random().toString(36).slice(2)}.tsx`;
|
|
14
|
+
|
|
15
|
+
describe('acquireFileLock', () => {
|
|
16
|
+
test('acquires + releases; release is idempotent', async () => {
|
|
17
|
+
const f = tmp('basic');
|
|
18
|
+
const rel = await acquireFileLock(f);
|
|
19
|
+
expect(await Bun.file(lockPathFor(f)).exists()).toBe(true);
|
|
20
|
+
await rel();
|
|
21
|
+
expect(await Bun.file(lockPathFor(f)).exists()).toBe(false);
|
|
22
|
+
await rel(); // second release is a no-op, never throws
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('serialises a second (cross-process) contender until the first releases', async () => {
|
|
26
|
+
const f = tmp('contend');
|
|
27
|
+
const relA = await acquireFileLock(f);
|
|
28
|
+
let bAcquired = false;
|
|
29
|
+
const bPromise = acquireFileLock(f).then((rel) => {
|
|
30
|
+
bAcquired = true;
|
|
31
|
+
return rel;
|
|
32
|
+
});
|
|
33
|
+
// While A holds the lock, B must be blocked (it's polling the lockfile).
|
|
34
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
35
|
+
expect(bAcquired).toBe(false);
|
|
36
|
+
await relA();
|
|
37
|
+
const relB = await bPromise; // B now wins the lock
|
|
38
|
+
expect(bAcquired).toBe(true);
|
|
39
|
+
await relB();
|
|
40
|
+
expect(await Bun.file(lockPathFor(f)).exists()).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('steals a STALE lock (holder crashed) instead of waiting forever', async () => {
|
|
44
|
+
const f = tmp('stale');
|
|
45
|
+
const lp = lockPathFor(f);
|
|
46
|
+
await Bun.write(lp, '99999 0'); // a "held" lock from a dead pid
|
|
47
|
+
const old = new Date(Date.now() - 60_000); // mtime 60s ago (> LOCK_STALE_MS)
|
|
48
|
+
await utimes(lp, old, old);
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
const rel = await acquireFileLock(f); // must steal it, not block
|
|
51
|
+
expect(Date.now() - start).toBeLessThan(2000);
|
|
52
|
+
await rel();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('withLock integration', () => {
|
|
57
|
+
test('an edit acquires + cleans up the cross-process lock', async () => {
|
|
58
|
+
const f = tmp('edit');
|
|
59
|
+
const src = 'function Demo() { return <div className="x">y</div>; }';
|
|
60
|
+
await Bun.write(f, src);
|
|
61
|
+
const { withIds } = transpileCanvasSource(f, src);
|
|
62
|
+
const id = withIds.match(/data-cd-id="([0-9a-f]{8})"/)?.[1] as string;
|
|
63
|
+
const r = await editAttribute(f, id, 'className', 'edited');
|
|
64
|
+
expect(r.changed).toBe(true);
|
|
65
|
+
expect(await Bun.file(f).text()).toContain('className="edited"');
|
|
66
|
+
// The lockfile must be released after the edit — not left dangling.
|
|
67
|
+
expect(await Bun.file(lockPathFor(f)).exists()).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('concurrent edits to the same file both land (serialised, no lost write)', async () => {
|
|
71
|
+
const f = tmp('concurrent');
|
|
72
|
+
const src = 'function Demo() { return <div className="x" title="t">y</div>; }';
|
|
73
|
+
await Bun.write(f, src);
|
|
74
|
+
const { withIds } = transpileCanvasSource(f, src);
|
|
75
|
+
const id = withIds.match(/data-cd-id="([0-9a-f]{8})"/)?.[1] as string;
|
|
76
|
+
await Promise.all([
|
|
77
|
+
editAttribute(f, id, 'className', 'cls-A'),
|
|
78
|
+
editAttribute(f, id, 'title', 'title-B'),
|
|
79
|
+
]);
|
|
80
|
+
const out = await Bun.file(f).text();
|
|
81
|
+
expect(out).toContain('cls-A');
|
|
82
|
+
expect(out).toContain('title-B'); // neither edit clobbered the other
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// video-comp-fixture.tsx — DDR-148 canonical video-comp fixture.
|
|
2
|
+
//
|
|
3
|
+
// A reusable comp exercising the full authoring vocabulary: a frame-driven
|
|
4
|
+
// animated title, two "clips" joined by a crossfade in a <TransitionSeries>,
|
|
5
|
+
// and a music-bed <Audio> with a volume envelope. Used by:
|
|
6
|
+
// • video-comp-fixture.test.ts — builds it through the real canvas pipeline
|
|
7
|
+
// (buildCanvasModule) and asserts remotion / @remotion/transitions stay
|
|
8
|
+
// external + <VideoComp> is present (CI-safe, no browser).
|
|
9
|
+
// • the live E2E sweep (drop → scrub → export) as a stable target.
|
|
10
|
+
//
|
|
11
|
+
// Deterministic BY CONSTRUCTION — every value is a pure function of the frame.
|
|
12
|
+
|
|
13
|
+
import { DCArtboard, DCSection, DesignCanvas, VideoComp } from '@maude/canvas-lib';
|
|
14
|
+
import { linearTiming, TransitionSeries } from '@remotion/transitions';
|
|
15
|
+
import { fade } from '@remotion/transitions/fade';
|
|
16
|
+
import { AbsoluteFill, Audio, interpolate, useCurrentFrame } from 'remotion';
|
|
17
|
+
|
|
18
|
+
const CLIP = 45;
|
|
19
|
+
const XF = 15;
|
|
20
|
+
const TOTAL = 2 * CLIP - XF; // two clips + one crossfade
|
|
21
|
+
|
|
22
|
+
const Beat = ({ label, bg }: { label: string; bg: string }) => {
|
|
23
|
+
const frame = useCurrentFrame();
|
|
24
|
+
const y = interpolate(frame, [0, 14], [24, 0], { extrapolateRight: 'clamp' });
|
|
25
|
+
const opacity = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: 'clamp' });
|
|
26
|
+
return (
|
|
27
|
+
<AbsoluteFill
|
|
28
|
+
style={{
|
|
29
|
+
background: bg,
|
|
30
|
+
color: '#fff',
|
|
31
|
+
alignItems: 'center',
|
|
32
|
+
justifyContent: 'center',
|
|
33
|
+
fontFamily: 'system-ui, sans-serif',
|
|
34
|
+
fontSize: 72,
|
|
35
|
+
fontWeight: 800,
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<div style={{ transform: `translateY(${y}px)`, opacity }}>{label}</div>
|
|
39
|
+
</AbsoluteFill>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function Fixture() {
|
|
44
|
+
return (
|
|
45
|
+
<AbsoluteFill>
|
|
46
|
+
<TransitionSeries>
|
|
47
|
+
<TransitionSeries.Sequence durationInFrames={CLIP}>
|
|
48
|
+
<Beat label="MAUDE" bg="#0f172a" />
|
|
49
|
+
</TransitionSeries.Sequence>
|
|
50
|
+
<TransitionSeries.Transition
|
|
51
|
+
presentation={fade()}
|
|
52
|
+
timing={linearTiming({ durationInFrames: XF })}
|
|
53
|
+
/>
|
|
54
|
+
<TransitionSeries.Sequence durationInFrames={CLIP}>
|
|
55
|
+
<Beat label="VIDEO" bg="#7c2d12" />
|
|
56
|
+
</TransitionSeries.Sequence>
|
|
57
|
+
</TransitionSeries>
|
|
58
|
+
<Audio
|
|
59
|
+
src="assets/music.mp3"
|
|
60
|
+
volume={(f) => interpolate(f, [TOTAL - 15, TOTAL], [0.7, 0], { extrapolateLeft: 'clamp' })}
|
|
61
|
+
/>
|
|
62
|
+
</AbsoluteFill>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function Canvas() {
|
|
67
|
+
return (
|
|
68
|
+
<DesignCanvas>
|
|
69
|
+
<DCSection title="Video-comp fixture">
|
|
70
|
+
<DCArtboard id="fixture" label="Fixture" width={640} height={360}>
|
|
71
|
+
<VideoComp
|
|
72
|
+
component={Fixture}
|
|
73
|
+
durationInFrames={TOTAL}
|
|
74
|
+
fps={30}
|
|
75
|
+
width={640}
|
|
76
|
+
height={360}
|
|
77
|
+
/>
|
|
78
|
+
</DCArtboard>
|
|
79
|
+
</DCSection>
|
|
80
|
+
</DesignCanvas>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -45,6 +45,32 @@ describe('history.ts', () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test('prunes to the newest MAX_SNAPSHOTS_PER_SLUG pairs (G3 disk-fill bound)', async () => {
|
|
49
|
+
const prev = process.env.MAUDE_MAX_SNAPSHOTS;
|
|
50
|
+
process.env.MAUDE_MAX_SNAPSHOTS = '3';
|
|
51
|
+
try {
|
|
52
|
+
await withSandbox(async (ctx) => {
|
|
53
|
+
const hist = createHistory(ctx);
|
|
54
|
+
for (let i = 0; i < 6; i++) {
|
|
55
|
+
// Space the writes so Date.now() advances between them — real
|
|
56
|
+
// structural edits are seconds apart, so each snapshot gets a distinct
|
|
57
|
+
// increasing ts (a same-millisecond burst is not a production shape).
|
|
58
|
+
await hist.writeSnapshot('.design/ui/a.html', `<doc>v${i}</doc>`, `edit-${i}`);
|
|
59
|
+
await Bun.sleep(2);
|
|
60
|
+
}
|
|
61
|
+
const list = await hist.listSnapshots('.design/ui/a.html');
|
|
62
|
+
// Only the 3 newest survive; the newest content is intact.
|
|
63
|
+
expect(list.length).toBe(3);
|
|
64
|
+
const newest = list[list.length - 1];
|
|
65
|
+
const read = await hist.readSnapshot('.design/ui/a.html', newest?.ts ?? '');
|
|
66
|
+
expect(new TextDecoder().decode(read?.content)).toBe('<doc>v5</doc>');
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
if (prev === undefined) delete process.env.MAUDE_MAX_SNAPSHOTS;
|
|
70
|
+
else process.env.MAUDE_MAX_SNAPSHOTS = prev;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
48
74
|
test('rollback overwrites the target file', async () => {
|
|
49
75
|
await withSandbox(async (ctx) => {
|
|
50
76
|
const hist = createHistory(ctx);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// KNOB_PROPS round-trip — feature-element-editing-robustness Stage B (Task B1
|
|
2
|
+
// + G2 item b). Promoting a property into `KNOB_PROPS` (dom-selection.ts) does
|
|
3
|
+
// two things: `styleMapsFor` captures its authored/computed value into the
|
|
4
|
+
// `Selection` payload (so the new Position/Transform/Typography/Media panel
|
|
5
|
+
// rows have something to render), AND it moves the prop OUT of the Advanced
|
|
6
|
+
// "customStyles" escape hatch — a canvas that had one of these as a raw custom
|
|
7
|
+
// style no longer double-surfaces it there.
|
|
8
|
+
//
|
|
9
|
+
// `hoverTargetToSelection` (the exported, already-tested entry point) spreads
|
|
10
|
+
// `...styleMapsFor(el)` into its return, so this exercises the real promoted
|
|
11
|
+
// list end-to-end without needing to export the private `styleMapsFor`/
|
|
12
|
+
// `KNOB_PROPS` internals.
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
GlobalRegistrator.register();
|
|
19
|
+
});
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
GlobalRegistrator.unregister();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { hoverTargetToSelection } from '../dom-selection.ts';
|
|
25
|
+
import type { HoverTarget } from '../input-router.tsx';
|
|
26
|
+
|
|
27
|
+
function selectionFor(styleText: string) {
|
|
28
|
+
document.body.innerHTML = `<div data-cd-id="aaaaaaaa" style="${styleText}">x</div>`;
|
|
29
|
+
const el = document.querySelector('[data-cd-id]') as HTMLElement;
|
|
30
|
+
const target: HoverTarget = { el, cdId: 'aaaaaaaa', artboardId: null };
|
|
31
|
+
return hoverTargetToSelection(target);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('KNOB_PROPS — the Stage-B promoted (formerly DDR-104 OUT-list) properties', () => {
|
|
35
|
+
test('position + inset (top/right/bottom/left) + z-index round-trip into authored', () => {
|
|
36
|
+
const sel = selectionFor(
|
|
37
|
+
'position: absolute; top: 10px; right: 20px; bottom: 30px; left: 40px; z-index: 5;'
|
|
38
|
+
);
|
|
39
|
+
expect(sel.authored?.position).toBe('absolute');
|
|
40
|
+
expect(sel.authored?.top).toBe('10px');
|
|
41
|
+
expect(sel.authored?.right).toBe('20px');
|
|
42
|
+
expect(sel.authored?.bottom).toBe('30px');
|
|
43
|
+
expect(sel.authored?.left).toBe('40px');
|
|
44
|
+
expect(sel.authored?.['z-index']).toBe('5');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('transform + transform-origin round-trip', () => {
|
|
48
|
+
const sel = selectionFor('transform: rotate(12deg); transform-origin: center;');
|
|
49
|
+
expect(sel.authored?.transform).toBe('rotate(12deg)');
|
|
50
|
+
expect(sel.authored?.['transform-origin']).toBe('center');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('font-style / text-transform / text-decoration / white-space round-trip', () => {
|
|
54
|
+
const sel = selectionFor(
|
|
55
|
+
'font-style: italic; text-transform: uppercase; text-decoration: underline; white-space: nowrap;'
|
|
56
|
+
);
|
|
57
|
+
expect(sel.authored?.['font-style']).toBe('italic');
|
|
58
|
+
expect(sel.authored?.['text-transform']).toBe('uppercase');
|
|
59
|
+
expect(sel.authored?.['text-decoration']).toBe('underline');
|
|
60
|
+
expect(sel.authored?.['white-space']).toBe('nowrap');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('overflow + media framing (object-fit/aspect-ratio/object-position) round-trip', () => {
|
|
64
|
+
const sel = selectionFor(
|
|
65
|
+
'overflow: hidden; object-fit: cover; aspect-ratio: 16 / 9; object-position: top;'
|
|
66
|
+
);
|
|
67
|
+
expect(sel.authored?.overflow).toBe('hidden');
|
|
68
|
+
expect(sel.authored?.['object-fit']).toBe('cover');
|
|
69
|
+
expect(sel.authored?.['aspect-ratio']).toBe('16 / 9');
|
|
70
|
+
expect(sel.authored?.['object-position']).toBe('top');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a promoted prop never leaks into customStyles (the B1 exclusion gotcha)', () => {
|
|
74
|
+
const sel = selectionFor('position: absolute; cursor: pointer;');
|
|
75
|
+
// `position` is curated now → excluded from the Advanced hatch.
|
|
76
|
+
expect(sel.customStyles?.position).toBeUndefined();
|
|
77
|
+
// A genuinely uncurated prop (not in KNOB_PROPS) still lands in
|
|
78
|
+
// customStyles as before — proves the exclusion is targeted, not a
|
|
79
|
+
// blanket "nothing goes to customStyles anymore" regression.
|
|
80
|
+
expect(sel.customStyles?.cursor).toBe('pointer');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('computed values are also captured (placeholder-hint source)', () => {
|
|
84
|
+
const sel = selectionFor('position: relative;');
|
|
85
|
+
expect(sel.computed?.position).toBe('relative');
|
|
86
|
+
});
|
|
87
|
+
});
|