@1agh/maude 0.39.0 → 0.40.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.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,230 @@
1
+ // Config hot-reload — `/design:setup-ds` rewrites `.design/config.json`
2
+ // mid-session; the server must re-read it so a newly added canvas group
3
+ // (`system`) reaches /_index-data and scaffolded DS files show in the tree.
4
+ // reloadConfig mutates ctx.cfg IN PLACE so every `const { cfg } = ctx`
5
+ // capture sees the fresh values.
6
+ // RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { type Context, createBus, reloadConfig } from '../context.ts';
14
+
15
+ /** The pre-scaffold e2e fixture shape — UI-only group, no design systems. */
16
+ const BOOT_CONFIG = {
17
+ name: 'e2e-fixture',
18
+ designRoot: '.design',
19
+ canvasGroups: [{ label: 'UI kit', path: 'ui' }],
20
+ designSystems: [],
21
+ };
22
+
23
+ /** What /design:setup-ds writes: system group + DS entry + tokens path. */
24
+ const SCAFFOLDED_CONFIG = {
25
+ ...BOOT_CONFIG,
26
+ tokensCssRel: 'system/kanban-glass/colors_and_type.css',
27
+ canvasGroups: [
28
+ { label: 'Design system', path: 'system' },
29
+ { label: 'UI kit', path: 'ui' },
30
+ ],
31
+ designSystems: [{ name: 'kanban-glass', path: 'system/kanban-glass' }],
32
+ defaultDesignSystem: 'kanban-glass',
33
+ };
34
+
35
+ function sandbox(): { root: string; designRoot: string } {
36
+ const root = mkdtempSync(join(tmpdir(), 'cfg-reload-test-'));
37
+ const designRoot = join(root, '.design');
38
+ mkdirSync(designRoot, { recursive: true });
39
+ writeFileSync(join(designRoot, 'config.json'), JSON.stringify(BOOT_CONFIG, null, 2));
40
+ return { root, designRoot };
41
+ }
42
+
43
+ /** Mirrors what createContext builds at boot from BOOT_CONFIG. */
44
+ function mkCtx(root: string, designRoot: string): Context {
45
+ return {
46
+ cfg: {
47
+ name: 'e2e-fixture',
48
+ projectLabel: null,
49
+ designRoot: '.design',
50
+ canvasGroups: [{ label: 'UI kit', path: 'ui' }],
51
+ designSystems: [],
52
+ rootClass: 'app',
53
+ themeDefault: 'dark',
54
+ tokensCssRel: 'system/colors_and_type.css',
55
+ teamAccentDefault: null,
56
+ handoffTargets: [],
57
+ newCanvasDir: 'ui',
58
+ newComponentDir: 'ui/components',
59
+ _source: '.design/config.json',
60
+ },
61
+ projectLabel: 'e2e-fixture Design',
62
+ paths: {
63
+ repoRoot: root,
64
+ designRel: '.design',
65
+ designRoot,
66
+ serverInfoFile: join(designRoot, '_server.json'),
67
+ activeFile: join(designRoot, '_active.json'),
68
+ commentsDir: join(designRoot, '_comments'),
69
+ canvasStateDir: join(designRoot, '_canvas-state'),
70
+ historyDir: join(designRoot, '_history'),
71
+ tokensUrlRel: '.design/system/colors_and_type.css',
72
+ systemDirRel: 'system',
73
+ },
74
+ bus: createBus(),
75
+ };
76
+ }
77
+
78
+ describe('reloadConfig', () => {
79
+ test('a group added on disk reaches a boot-time captured cfg reference (the bug)', () => {
80
+ const { root, designRoot } = sandbox();
81
+ const ctx = mkCtx(root, designRoot);
82
+ // What every module factory holds: the object reference, captured at boot.
83
+ const captured = ctx.cfg;
84
+ try {
85
+ writeFileSync(join(designRoot, 'config.json'), JSON.stringify(SCAFFOLDED_CONFIG, null, 2));
86
+ expect(reloadConfig(ctx)).toBe(true);
87
+ // The capture sees the scaffolded groups — no restart needed.
88
+ expect(captured.canvasGroups.map((g) => g.path)).toEqual(['system', 'ui']);
89
+ expect(captured.designSystems?.[0]?.name).toBe('kanban-glass');
90
+ // normalizeDesignSystems runs on reload too (derived per-DS tokens path).
91
+ expect(captured.designSystems?.[0]?.tokensCssRel).toBe(
92
+ 'system/kanban-glass/colors_and_type.css'
93
+ );
94
+ expect(captured.defaultDesignSystem).toBe('kanban-glass');
95
+ // Derived paths recompute in place.
96
+ expect(ctx.paths.tokensUrlRel).toBe('.design/system/kanban-glass/colors_and_type.css');
97
+ expect(ctx.paths.systemDirRel).toBe('system');
98
+ } finally {
99
+ rmSync(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test('an unchanged config reports false (no spurious nudges)', () => {
104
+ const { root, designRoot } = sandbox();
105
+ const ctx = mkCtx(root, designRoot);
106
+ try {
107
+ reloadConfig(ctx); // sync ctx.cfg to the on-disk file once
108
+ expect(reloadConfig(ctx)).toBe(false);
109
+ } finally {
110
+ rmSync(root, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test('invalid JSON mid-edit keeps the running config', () => {
115
+ const { root, designRoot } = sandbox();
116
+ const ctx = mkCtx(root, designRoot);
117
+ try {
118
+ writeFileSync(join(designRoot, 'config.json'), '{ "name": "half-writ');
119
+ expect(reloadConfig(ctx)).toBe(false);
120
+ expect(ctx.cfg.name).toBe('e2e-fixture');
121
+ expect(ctx.cfg.canvasGroups.map((g) => g.path)).toEqual(['ui']);
122
+ } finally {
123
+ rmSync(root, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('a deleted config keeps the running config (no downgrade to defaults)', () => {
128
+ const { root, designRoot } = sandbox();
129
+ const ctx = mkCtx(root, designRoot);
130
+ try {
131
+ unlinkSync(join(designRoot, 'config.json'));
132
+ expect(reloadConfig(ctx)).toBe(false);
133
+ expect(ctx.cfg.name).toBe('e2e-fixture');
134
+ expect(ctx.cfg._source).toBe('.design/config.json');
135
+ } finally {
136
+ rmSync(root, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test('designRoot is not hot-reloadable — kept, rest of the change applies', () => {
141
+ const { root, designRoot } = sandbox();
142
+ const ctx = mkCtx(root, designRoot);
143
+ try {
144
+ writeFileSync(
145
+ join(designRoot, 'config.json'),
146
+ JSON.stringify({ ...BOOT_CONFIG, name: 'renamed', designRoot: 'designs' }, null, 2)
147
+ );
148
+ expect(reloadConfig(ctx)).toBe(true);
149
+ expect(ctx.cfg.name).toBe('renamed');
150
+ expect(ctx.cfg.designRoot).toBe('.design');
151
+ expect(ctx.paths.designRel).toBe('.design');
152
+ expect(ctx.projectLabel).toBe('renamed Design');
153
+ } finally {
154
+ rmSync(root, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test('linkedHub is boot-pinned — a live add is ignored, the rest applies', () => {
159
+ const { root, designRoot } = sandbox();
160
+ const ctx = mkCtx(root, designRoot);
161
+ try {
162
+ writeFileSync(
163
+ join(designRoot, 'config.json'),
164
+ JSON.stringify(
165
+ {
166
+ ...BOOT_CONFIG,
167
+ name: 'renamed',
168
+ linkedHub: { url: 'wss://evil.example', linkedAt: 1, syncTsx: true },
169
+ },
170
+ null,
171
+ 2
172
+ )
173
+ );
174
+ expect(reloadConfig(ctx)).toBe(true);
175
+ expect(ctx.cfg.name).toBe('renamed');
176
+ expect(ctx.cfg.linkedHub).toBeUndefined();
177
+ } finally {
178
+ rmSync(root, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test('design-root escapes are clamped: bad group dropped, bad tokensCssRel reset', () => {
183
+ const { root, designRoot } = sandbox();
184
+ const ctx = mkCtx(root, designRoot);
185
+ try {
186
+ writeFileSync(
187
+ join(designRoot, 'config.json'),
188
+ JSON.stringify(
189
+ {
190
+ ...SCAFFOLDED_CONFIG,
191
+ tokensCssRel: '../../../etc/passwd',
192
+ canvasGroups: [
193
+ { label: 'Escape', path: '../..' },
194
+ { label: 'Abs', path: '/etc' },
195
+ { label: 'UI kit', path: 'ui' },
196
+ ],
197
+ designSystems: [
198
+ { name: 'evil', path: '../outside' },
199
+ { name: 'kanban-glass', path: 'system/kanban-glass' },
200
+ ],
201
+ },
202
+ null,
203
+ 2
204
+ )
205
+ );
206
+ expect(reloadConfig(ctx)).toBe(true);
207
+ expect(ctx.cfg.canvasGroups.map((g) => g.path)).toEqual(['ui']);
208
+ expect(ctx.cfg.tokensCssRel).toBe('system/colors_and_type.css');
209
+ expect(ctx.cfg.designSystems?.map((d) => d.name)).toEqual(['kanban-glass']);
210
+ } finally {
211
+ rmSync(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test('a config.json created after a defaults boot applies', () => {
216
+ const { root, designRoot } = sandbox();
217
+ const ctx = mkCtx(root, designRoot);
218
+ // Simulate a boot without config.json (createContext fell back to defaults).
219
+ unlinkSync(join(designRoot, 'config.json'));
220
+ ctx.cfg._source = 'defaults';
221
+ try {
222
+ writeFileSync(join(designRoot, 'config.json'), JSON.stringify(SCAFFOLDED_CONFIG, null, 2));
223
+ expect(reloadConfig(ctx)).toBe(true);
224
+ expect(ctx.cfg._source).toBe('.design/config.json');
225
+ expect(ctx.cfg.canvasGroups.map((g) => g.path)).toEqual(['system', 'ui']);
226
+ } finally {
227
+ rmSync(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+ });
@@ -0,0 +1,74 @@
1
+ // DNS-rebinding guard on the main-origin source-write/read family.
2
+ //
3
+ // The dev-server binds 127.0.0.1, but `sameOriginWrite` is a *reflective* Origin
4
+ // check — after a DNS-rebind (`evil.example → 127.0.0.1`) the browser sends
5
+ // `Origin: http://evil.example:<port>` AND `Host: evil.example:<port>`, so both
6
+ // sides of the origin comparison are attacker-controlled and equal → CSRF passes.
7
+ // Every source-write/read route now also requires a loopback `Host` (the same
8
+ // `isLoopbackHost` guard the /_api/git/* routes already carry). A real browser
9
+ // always sends `Host: localhost|127.0.0.1:<port>` → passes; a rebound foreign
10
+ // hostname → 403. Regression guard for the /flow:done DDR-150 attacker finding.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { mkdirSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
17
+
18
+ describe('DNS-rebinding guard — source-write/read family requires a loopback Host', () => {
19
+ test('rebound Host 403s writes + reads; a loopback Host passes the guard', async () => {
20
+ const { root, designRoot } = makeSandbox();
21
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
22
+ writeFileSync(join(designRoot, 'ui', 'C.tsx'), 'export default function C(){return <main/>}\n');
23
+
24
+ const port = nextPort();
25
+ const proc = await bootServer(root, port);
26
+ try {
27
+ const base = `http://localhost:${port}`;
28
+ // A rebound page: its Host header is the attacker's rebound hostname.
29
+ const evilHost = `evil.example:${port}`;
30
+
31
+ // WRITE route (POST) — the loopback guard fires (before the op runs).
32
+ const wEvil = await fetch(`${base}/_api/toggle-hide`, {
33
+ method: 'POST',
34
+ headers: { 'content-type': 'application/json', host: evilHost },
35
+ body: JSON.stringify({ canvas: 'ui/C.tsx', stableId: 'x' }),
36
+ });
37
+ expect(wEvil.status).toBe(403);
38
+
39
+ // A second write route, to prove it's the whole family, not one endpoint.
40
+ const wEvil2 = await fetch(`${base}/_api/edit-text`, {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/json', host: evilHost },
43
+ body: JSON.stringify({ canvas: 'ui/C.tsx', id: 'x', text: 'y' }),
44
+ });
45
+ expect(wEvil2.status).toBe(403);
46
+
47
+ // READ routes (GET) — a rebound same-origin page could otherwise CORS-read
48
+ // raw project source; the guard 403s them too.
49
+ const rEvil = await fetch(`${base}/_api/canvas-source?file=ui/C.tsx`, {
50
+ headers: { host: evilHost },
51
+ });
52
+ expect(rEvil.status).toBe(403);
53
+ const rEvil2 = await fetch(`${base}/_api/comp-clips?canvas=ui/C.tsx`, {
54
+ headers: { host: evilHost },
55
+ });
56
+ expect(rEvil2.status).toBe(403);
57
+
58
+ // A real browser (loopback Host) is NOT rejected by the rebind guard — the
59
+ // source read succeeds (200). Proves the guard doesn't false-positive on
60
+ // the legitimate local request every real user makes.
61
+ const rOk = await fetch(`${base}/_api/canvas-source?file=ui/C.tsx`, {
62
+ headers: { host: `localhost:${port}` },
63
+ });
64
+ expect(rOk.status).toBe(200);
65
+ // 127.0.0.1 Host is equally valid (the printed URL variant).
66
+ const rOk2 = await fetch(`${base}/_api/canvas-source?file=ui/C.tsx`, {
67
+ headers: { host: `127.0.0.1:${port}` },
68
+ });
69
+ expect(rOk2.status).toBe(200);
70
+ } finally {
71
+ await killProc(proc);
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,91 @@
1
+ // edit-persistence — DDR-148 dogfood. A CSS edit on a video-comp element must
2
+ // survive into the RENDERED module, not just the optimistic DOM overlay — i.e.
3
+ // source edit → build → the new value is in the compiled canvas the Player runs.
4
+ // This end-to-end guard would have caught the original "my color reverts when
5
+ // the video replays" bug (a trailing-comma insert produced `opacity: o, , color`
6
+ // → the build FAILED → the Player kept rendering the OLD source).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { mkdtempSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { buildCanvasModule } from '../canvas-build.ts';
13
+ import { applyEdit } from '../canvas-edit.ts';
14
+ import { transpileCanvasSource } from '../canvas-pipeline.ts';
15
+
16
+ const CANVAS = '/tmp/edit-persist.tsx';
17
+
18
+ /** buildCanvasModule needs the entrypoint on disk — write the source, then build. */
19
+ async function buildAt(source: string) {
20
+ const dir = mkdtempSync(join(tmpdir(), 'edit-persist-'));
21
+ const abs = join(dir, 'Comp.tsx');
22
+ writeFileSync(abs, source);
23
+ return buildCanvasModule(abs, source);
24
+ }
25
+
26
+ // A frame-driven letter span (the exact shape from the intro demo): computed
27
+ // transform/opacity + a trailing comma on the last property.
28
+ const COMP = `import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
29
+ export default function Comp() {
30
+ const frame = useCurrentFrame();
31
+ const y = interpolate(frame, [0, 20], [40, 0]);
32
+ const o = interpolate(frame, [0, 20], [0, 1]);
33
+ return (
34
+ <AbsoluteFill>
35
+ <span
36
+ style={{
37
+ fontSize: 132,
38
+ fontWeight: 800,
39
+ transform: \`translateY(\${y}px)\`,
40
+ opacity: o,
41
+ color: '#f0ece5',
42
+ }}
43
+ >
44
+ maude
45
+ </span>
46
+ </AbsoluteFill>
47
+ );
48
+ }
49
+ `;
50
+
51
+ function spanId(source: string): string {
52
+ const { withIds } = transpileCanvasSource(CANVAS, source);
53
+ const m = withIds.match(/<span[^>]*?data-cd-id="([0-9a-f]{8})"/);
54
+ if (!m) throw new Error('no span id');
55
+ return m[1] as string;
56
+ }
57
+
58
+ describe('edit-persistence — a CSS edit survives into the built module', () => {
59
+ test('SWAP an existing color → the new color is in the compiled canvas', async () => {
60
+ const id = spanId(COMP);
61
+ const edited = applyEdit(CANVAS, COMP, id, 'style.color', "'#ff0000'").source;
62
+ expect(edited).toContain("color: '#ff0000'");
63
+ // The value the Player actually renders is the BUILT module — assert it there.
64
+ const built = await buildAt(edited);
65
+ expect(built.js).toContain('#ff0000');
66
+ expect(built.js).not.toContain('#f0ece5'); // old value fully replaced
67
+ });
68
+
69
+ test('INSERT a new prop next to a TRAILING comma → still builds + renders (the bug)', async () => {
70
+ const id = spanId(COMP);
71
+ // Insert a brand-new property where the last one (color) ends with a comma.
72
+ const edited = applyEdit(CANVAS, COMP, id, 'style.letterSpacing', "'-0.04em'").source;
73
+ expect(edited).not.toMatch(/,\s*,/); // NO double comma (the crash that reverted the render)
74
+ const built = await buildAt(edited); // must not throw
75
+ expect(built.js).toContain('-0.04em');
76
+ expect(built.js).toContain('#f0ece5'); // untouched props survive
77
+ });
78
+
79
+ test('a SEQUENCE of edits all persist (no edit clobbers a prior one)', async () => {
80
+ let src = COMP;
81
+ const id = spanId(src);
82
+ src = applyEdit(CANVAS, src, id, 'style.color', "'#00ff00'").source;
83
+ src = applyEdit(CANVAS, src, id, 'style.fontWeight', '900').source;
84
+ src = applyEdit(CANVAS, src, id, 'style.fontSize', '148').source;
85
+ const built = await buildAt(src);
86
+ expect(built.js).toContain('#00ff00');
87
+ expect(built.js).toContain('148');
88
+ // the frame-driven values must still be present (edits never touched them)
89
+ expect(built.js).toContain('translateY');
90
+ });
91
+ });
@@ -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
+ });
@@ -5,6 +5,16 @@
5
5
  // `agent_message_chunk` whose text echoes whether ANTHROPIC_API_KEY survived
6
6
  // into the child env — it MUST read `<unset>`, proving scrubAgentEnv (DDR-123
7
7
  // guardrail #1) stripped it before spawn.
8
+ //
9
+ // session/load simulates the REAL adapter's cross-restart resume (this is a
10
+ // fresh subprocess per test, so it has no memory of a prior session/new call —
11
+ // exactly like the real claude-agent-acp adapter after an app restart, whose
12
+ // resume is backed by the underlying `claude` CLI's own on-disk store, not its
13
+ // own process memory). Any sessionId resolves EXCEPT the `not-found` sentinel
14
+ // (simulates a pruned/unresumable session), so a test controls resume
15
+ // success/failure by choosing which id it persists. It also emits one replay
16
+ // `session/update` BEFORE resolving, mirroring claude-agent-acp's
17
+ // `replaySessionHistory` — bridge.ts must not forward or re-transcript it.
8
18
 
9
19
  import { Readable, Writable } from 'node:stream';
10
20
 
@@ -16,7 +26,7 @@ acp
16
26
  .agent({ name: 'mock-acp-agent' })
17
27
  .onRequest('initialize', () => ({
18
28
  protocolVersion: acp.PROTOCOL_VERSION,
19
- agentCapabilities: { loadSession: false },
29
+ agentCapabilities: { loadSession: true },
20
30
  }))
21
31
  .onRequest(
22
32
  'session/new',
@@ -25,6 +35,19 @@ acp
25
35
  return () => ({ sessionId: `mock-session-${++n}` });
26
36
  })()
27
37
  )
38
+ .onRequest('session/load', async (ctx) => {
39
+ if (ctx.params.sessionId === 'not-found') {
40
+ throw new Error('session not found');
41
+ }
42
+ await ctx.client.notify('session/update', {
43
+ sessionId: ctx.params.sessionId,
44
+ update: {
45
+ sessionUpdate: 'agent_message_chunk',
46
+ content: { type: 'text', text: 'REPLAYED-HISTORY-SHOULD-NOT-SURFACE' },
47
+ },
48
+ });
49
+ return {};
50
+ })
28
51
  .onRequest('session/prompt', async (ctx) => {
29
52
  // The per-request handler context exposes the client connection at `.client`.
30
53
  await ctx.client.notify('session/update', {
@@ -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
+ }