@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.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,85 @@
1
+ // timeline-snap.test.ts — DDR-150 Task 15 (Polish). The pure snap helpers behind
2
+ // clip move/trim: a dragged edge lands on the nearest second-tick / neighbor edge
3
+ // / playhead within a threshold, and Alt (threshold 0) turns it off.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ computeSnapTargets,
9
+ snapFrame,
10
+ snapThresholdFrames,
11
+ } from '../client/panels/timeline-snap.js';
12
+
13
+ describe('computeSnapTargets', () => {
14
+ test('includes 0, comp end, second ticks, neighbor edges, playhead — excludes the moving clip', () => {
15
+ const targets = computeSnapTargets({
16
+ fps: 30,
17
+ totalFrames: 120,
18
+ clips: [
19
+ { from: 0, duration: 30 }, // clip 0 — the one being moved (excluded)
20
+ { from: 45, duration: 30 }, // clip 1 — edges 45 + 75 should appear
21
+ ],
22
+ movingIndex: 0,
23
+ playhead: 88,
24
+ });
25
+ expect(targets).toContain(0); // start
26
+ expect(targets).toContain(120); // comp end
27
+ expect(targets).toContain(30); // second tick (1s @ 30fps)
28
+ expect(targets).toContain(60); // second tick (2s)
29
+ expect(targets).toContain(90); // second tick (3s)
30
+ expect(targets).toContain(45); // neighbor clip start
31
+ expect(targets).toContain(75); // neighbor clip end
32
+ expect(targets).toContain(88); // playhead
33
+ // the MOVING clip's own edges (0 already a tick; its end 30 is also a tick,
34
+ // but must not be added as a *clip edge* — dedup keeps it single + sorted)
35
+ expect(targets).toEqual([...targets].sort((a, b) => a - b));
36
+ expect(new Set(targets).size).toBe(targets.length); // deduped
37
+ });
38
+
39
+ test('clamps targets to [0, totalFrames]', () => {
40
+ const targets = computeSnapTargets({
41
+ fps: 30,
42
+ totalFrames: 50,
43
+ clips: [{ from: 40, duration: 40 }], // end 80 is out of range → dropped
44
+ movingIndex: -1,
45
+ playhead: 200, // out of range → dropped
46
+ });
47
+ expect(Math.max(...targets)).toBeLessThanOrEqual(50);
48
+ expect(targets).not.toContain(80);
49
+ expect(targets).not.toContain(200);
50
+ });
51
+ });
52
+
53
+ describe('snapFrame', () => {
54
+ const targets = [0, 30, 45, 60, 90, 120];
55
+
56
+ test('snaps to the nearest target within threshold', () => {
57
+ expect(snapFrame(32, targets, 4)).toBe(30); // 2 away
58
+ expect(snapFrame(43, targets, 4)).toBe(45); // 2 away
59
+ });
60
+
61
+ test('leaves the candidate unchanged when nothing is in range', () => {
62
+ expect(snapFrame(52, targets, 4)).toBe(52); // nearest (45/60) is 7/8 away
63
+ });
64
+
65
+ test('picks the CLOSER of two in-range targets', () => {
66
+ // 47 is 2 from 45 and 13 from 60 → 45.
67
+ expect(snapFrame(47, targets, 20)).toBe(45);
68
+ });
69
+
70
+ test('threshold 0 (Alt override) never snaps', () => {
71
+ expect(snapFrame(30, targets, 0)).toBe(30);
72
+ expect(snapFrame(31, targets, 0)).toBe(31);
73
+ });
74
+ });
75
+
76
+ describe('snapThresholdFrames', () => {
77
+ test('converts an ~8px radius into frames by the row width', () => {
78
+ // 8px on a 800px row spanning 400 frames → ~4 frames.
79
+ expect(Math.round(snapThresholdFrames(800, 401, 8))).toBe(4);
80
+ });
81
+
82
+ test('degrades safely on a zero-width row', () => {
83
+ expect(Number.isFinite(snapThresholdFrames(0, 100))).toBe(true);
84
+ });
85
+ });
@@ -0,0 +1,84 @@
1
+ // tool-palette.tsx — Stage I3 tail (feature-element-editing-robustness). The
2
+ // "+ Element" affordance's anchor resolver: appends relative to the ACTIVE
3
+ // artboard's last existing child (mirrors the context-menu's element-relative
4
+ // insert, since an artboard's own `<article data-dc-screen>` carries no
5
+ // `data-cd-id` of its own — DCArtboard doesn't forward it to the DOM).
6
+ //
7
+ // Needs a live DOM (querySelector/:scope) — register happy-dom for this file
8
+ // only, like canvas-hmr-runtime.test.tsx.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
11
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
12
+
13
+ beforeAll(() => {
14
+ GlobalRegistrator.register();
15
+ });
16
+ afterAll(() => {
17
+ GlobalRegistrator.unregister();
18
+ });
19
+
20
+ import { resolveInsertAnchor } from '../tool-palette.tsx';
21
+
22
+ describe('tool-palette / resolveInsertAnchor', () => {
23
+ test('anchors on the last data-cd-id child of the CURRENT (aria-current) artboard', () => {
24
+ document.body.innerHTML = `
25
+ <article data-dc-screen="a1">
26
+ <div class="dc-artboard-body">
27
+ <div data-cd-id="11111111">one</div>
28
+ <div data-cd-id="22222222">two</div>
29
+ </div>
30
+ </article>
31
+ <article data-dc-screen="a2" aria-current="true">
32
+ <div class="dc-artboard-body">
33
+ <div data-cd-id="33333333">three</div>
34
+ <div data-cd-id="44444444">four</div>
35
+ </div>
36
+ </article>
37
+ `;
38
+ expect(resolveInsertAnchor(document)).toEqual({ refId: '44444444', position: 'after' });
39
+ });
40
+
41
+ test('falls back to the FIRST artboard when none is aria-current', () => {
42
+ document.body.innerHTML = `
43
+ <article data-dc-screen="a1">
44
+ <div class="dc-artboard-body"><div data-cd-id="aaaaaaaa">x</div></div>
45
+ </article>
46
+ <article data-dc-screen="a2">
47
+ <div class="dc-artboard-body"><div data-cd-id="bbbbbbbb">y</div></div>
48
+ </article>
49
+ `;
50
+ expect(resolveInsertAnchor(document)).toEqual({ refId: 'aaaaaaaa', position: 'after' });
51
+ });
52
+
53
+ test('skips a chrome direct-child with no data-cd-id, picking the last STAMPED one', () => {
54
+ document.body.innerHTML = `
55
+ <article data-dc-screen="a1" aria-current="true">
56
+ <div class="dc-artboard-body">
57
+ <div data-cd-id="11111111">one</div>
58
+ <div class="some-chrome-node">no cd-id</div>
59
+ </div>
60
+ </article>
61
+ `;
62
+ expect(resolveInsertAnchor(document)).toEqual({ refId: '11111111', position: 'after' });
63
+ });
64
+
65
+ test('returns null for an EMPTY artboard (no data-cd-id child to anchor on)', () => {
66
+ document.body.innerHTML = `
67
+ <article data-dc-screen="a1" aria-current="true">
68
+ <div class="dc-artboard-body"></div>
69
+ </article>
70
+ `;
71
+ expect(resolveInsertAnchor(document)).toBeNull();
72
+ });
73
+
74
+ test('returns null when there is no artboard at all', () => {
75
+ document.body.innerHTML = '<div>no artboards here</div>';
76
+ expect(resolveInsertAnchor(document)).toBeNull();
77
+ });
78
+
79
+ test('returns null when the artboard has no .dc-artboard-body wrapper', () => {
80
+ document.body.innerHTML =
81
+ '<article data-dc-screen="a1"><div data-cd-id="11111111">x</div></article>';
82
+ expect(resolveInsertAnchor(document)).toBeNull();
83
+ });
84
+ });
@@ -0,0 +1,211 @@
1
+ // Stage K (Task K1, feature-element-editing-robustness) — the scripted
2
+ // mixed-op undo sequence the plan's INV-1 asks for: resize → delete → insert
3
+ // → move → spacing-drag → Cmd+Z ×5 → canvas byte-identical to the start.
4
+ //
5
+ // Drives the SAME HTTP routes the client's undo mechanisms call:
6
+ // - CSS-shaped ops (resize/move/spacing) undo by re-POSTing `/_api/edit-css`
7
+ // with the pre-edit value — exactly what `recordSourceEdit` +
8
+ // `commands/edit-source-command.ts`'s `undo()` do (do/undo are both a
9
+ // single `applyFn` call with a different `value`, so there is no
10
+ // separate "revert" endpoint to test — the HTTP-level round-trip IS the
11
+ // undo mechanism).
12
+ // - Structural ops (delete/insert) undo via `/_api/reorder-revert`, the
13
+ // whole-file seq-log swap Stage I reuses from Phase 12.1's reorder undo
14
+ // (`commands/reorder-command.ts`) rather than a bespoke command per op.
15
+ //
16
+ // `/_api/reorder-revert` 409s if the file drifted since the logged seq (its
17
+ // own doc comment: "refuses 409 when the canvas changed since") — so the 5
18
+ // undos below MUST run in strict reverse (LIFO) order, exactly like a real
19
+ // Cmd+Z stack, or the structural reverts would fail closed. That ordering
20
+ // constraint is itself part of what this test proves.
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
27
+
28
+ // Style values use DOUBLE quotes throughout — matching this codebase's own
29
+ // authored-JSX convention (canvas-lib.tsx, scaffold-design.ts): editCss's
30
+ // underlying write is a JSON.stringify literal, which is always
31
+ // double-quoted regardless of the original source's quote style. A
32
+ // single-quoted fixture would make a value round-trip only VALUE-identical,
33
+ // not byte-identical (a cosmetic quote-style delta, not a real undo gap) —
34
+ // double-quoting the fixture up front keeps this test's byte-compare
35
+ // meaningful instead of tripping over that unrelated cosmetic mismatch.
36
+ const CANVAS_SRC = `export default function Seq() {
37
+ return (
38
+ <DesignCanvas>
39
+ <DCArtboard id="home" label="Home" width={1440} height={1024}>
40
+ <figure style={{ position: "absolute", left: "20px", top: "20px", width: "100px", height: "60px" }}>Box</figure>
41
+ <div>A</div>
42
+ <div>B</div>
43
+ <div>C</div>
44
+ <section style={{ display: "flex", paddingLeft: "8px" }}>
45
+ <span>x</span>
46
+ <span>y</span>
47
+ </section>
48
+ </DCArtboard>
49
+ </DesignCanvas>
50
+ );
51
+ }
52
+ `;
53
+
54
+ interface LocatorEntry {
55
+ line: number;
56
+ jsxPath: string[];
57
+ }
58
+
59
+ async function locatorMap(main: string, designRoot: string): Promise<Record<string, LocatorEntry>> {
60
+ const r = await fetch(`${main}/.design/ui/Seq.tsx`, { signal: AbortSignal.timeout(2000) });
61
+ expect(r.status).toBe(200);
62
+ const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
63
+ return locator['ui/Seq'] as Record<string, LocatorEntry>;
64
+ }
65
+
66
+ /** First (only, in this fixture) data-cd-id whose JSX tag is `tag`. */
67
+ function idOf(map: Record<string, LocatorEntry>, tag: string): string {
68
+ const hit = Object.entries(map).find(([, e]) => e.jsxPath[e.jsxPath.length - 1] === tag);
69
+ expect(hit).toBeDefined();
70
+ return hit?.[0] as string;
71
+ }
72
+
73
+ function idsByTag(map: Record<string, LocatorEntry>, tag: string): string[] {
74
+ return Object.entries(map)
75
+ .filter(([, e]) => e.jsxPath[e.jsxPath.length - 1] === tag)
76
+ .sort((a, b) => a[1].line - b[1].line)
77
+ .map(([id]) => id);
78
+ }
79
+
80
+ async function editCss(
81
+ main: string,
82
+ body: { canvas: string; id: string; property: string; value: string }
83
+ ): Promise<{ ok: boolean; error?: string }> {
84
+ const res = await fetch(`${main}/_api/edit-css`, {
85
+ method: 'POST',
86
+ body: JSON.stringify(body),
87
+ signal: AbortSignal.timeout(2000),
88
+ });
89
+ return (await res.json()) as { ok: boolean; error?: string };
90
+ }
91
+
92
+ describe('Stage K1 — mixed resize/delete/insert/move/spacing undo sequence', () => {
93
+ test('5 undos (LIFO) return the canvas byte-identical to its start', async () => {
94
+ const { root, designRoot } = makeSandbox();
95
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
96
+ writeFileSync(join(designRoot, 'ui', 'Seq.tsx'), CANVAS_SRC);
97
+ const port = nextPort();
98
+ const proc = await bootServer(root, port);
99
+ const main = `http://localhost:${port}`;
100
+ const canvasPath = join(designRoot, 'ui', 'Seq.tsx');
101
+ const original = readFileSync(canvasPath, 'utf8');
102
+ expect(original).toBe(CANVAS_SRC);
103
+
104
+ try {
105
+ // ── 1. resize (CSS-shaped) ──────────────────────────────────────────
106
+ let map = await locatorMap(main, designRoot);
107
+ const boxId = idOf(map, 'figure');
108
+ const resize = await editCss(main, {
109
+ canvas: 'ui/Seq',
110
+ id: boxId,
111
+ property: 'width',
112
+ value: '140px',
113
+ });
114
+ expect(resize.ok).toBe(true);
115
+ expect(readFileSync(canvasPath, 'utf8')).toContain('width: "140px"');
116
+
117
+ // ── 2. delete (structural) ──────────────────────────────────────────
118
+ map = await locatorMap(main, designRoot);
119
+ const [aId, bId] = idsByTag(map, 'div');
120
+ const del = await fetch(`${main}/_api/delete-element`, {
121
+ method: 'POST',
122
+ body: JSON.stringify({ canvas: 'ui/Seq', id: bId }),
123
+ signal: AbortSignal.timeout(2000),
124
+ });
125
+ expect(del.status).toBe(200);
126
+ const delJson = (await del.json()) as { ok: boolean; seq: number };
127
+ expect(delJson.ok).toBe(true);
128
+ expect(
129
+ [...readFileSync(canvasPath, 'utf8').matchAll(/<div>([ABC])</g)].map((m) => m[1])
130
+ ).toEqual(['A', 'C']);
131
+
132
+ // ── 3. insert (structural) ──────────────────────────────────────────
133
+ const ins = await fetch(`${main}/_api/insert-element`, {
134
+ method: 'POST',
135
+ body: JSON.stringify({ canvas: 'ui/Seq', refId: aId, position: 'after', kind: 'div' }),
136
+ signal: AbortSignal.timeout(2000),
137
+ });
138
+ expect(ins.status).toBe(200);
139
+ const insJson = (await ins.json()) as { ok: boolean; seq: number; newId: string | null };
140
+ expect(insJson.ok).toBe(true);
141
+
142
+ // ── 4. move (CSS-shaped, same box element as step 1) ────────────────
143
+ map = await locatorMap(main, designRoot);
144
+ const boxIdAfterStructural = idOf(map, 'figure');
145
+ const move = await editCss(main, {
146
+ canvas: 'ui/Seq',
147
+ id: boxIdAfterStructural,
148
+ property: 'left',
149
+ value: '60px',
150
+ });
151
+ expect(move.ok).toBe(true);
152
+ expect(readFileSync(canvasPath, 'utf8')).toContain('left: "60px"');
153
+
154
+ // ── 5. spacing-drag (CSS-shaped, padding-left) ──────────────────────
155
+ map = await locatorMap(main, designRoot);
156
+ const sectionId = idOf(map, 'section');
157
+ const spacing = await editCss(main, {
158
+ canvas: 'ui/Seq',
159
+ id: sectionId,
160
+ property: 'padding-left',
161
+ value: '24px',
162
+ });
163
+ expect(spacing.ok).toBe(true);
164
+ expect(readFileSync(canvasPath, 'utf8')).toContain('paddingLeft: "24px"');
165
+
166
+ // ── Cmd+Z ×5 — strict LIFO ───────────────────────────────────────────
167
+ const undo5 = await editCss(main, {
168
+ canvas: 'ui/Seq',
169
+ id: sectionId,
170
+ property: 'padding-left',
171
+ value: '8px',
172
+ });
173
+ expect(undo5.ok).toBe(true);
174
+
175
+ const undo4 = await editCss(main, {
176
+ canvas: 'ui/Seq',
177
+ id: boxIdAfterStructural,
178
+ property: 'left',
179
+ value: '20px',
180
+ });
181
+ expect(undo4.ok).toBe(true);
182
+
183
+ const undo3 = await fetch(`${main}/_api/reorder-revert`, {
184
+ method: 'POST',
185
+ body: JSON.stringify({ canvas: 'ui/Seq', seq: insJson.seq, dir: 'undo' }),
186
+ signal: AbortSignal.timeout(2000),
187
+ });
188
+ expect(undo3.status).toBe(200);
189
+
190
+ const undo2 = await fetch(`${main}/_api/reorder-revert`, {
191
+ method: 'POST',
192
+ body: JSON.stringify({ canvas: 'ui/Seq', seq: delJson.seq, dir: 'undo' }),
193
+ signal: AbortSignal.timeout(2000),
194
+ });
195
+ expect(undo2.status).toBe(200);
196
+
197
+ const undo1 = await editCss(main, {
198
+ canvas: 'ui/Seq',
199
+ id: boxId,
200
+ property: 'width',
201
+ value: '100px',
202
+ });
203
+ expect(undo1.ok).toBe(true);
204
+
205
+ // ── Assert: byte-identical to the pre-sequence source ───────────────
206
+ expect(readFileSync(canvasPath, 'utf8')).toBe(original);
207
+ } finally {
208
+ await killProc(proc);
209
+ }
210
+ });
211
+ });
@@ -0,0 +1,163 @@
1
+ // POST /_api/asset — DDR-148 video/audio widening of the Phase 23 asset route.
2
+ //
3
+ // Two layers (mirroring asset-api.test.ts):
4
+ // (1) the pure magic-byte sniff (`sniffAssetType`) — the ONE gate that decides
5
+ // the stored type AND category (→ which cap applies); a header lie / SVG /
6
+ // script is caught here;
7
+ // (2) the streaming POST endpoint (boots a real server) — video/audio happy
8
+ // path, per-category cap (image 10 MB stays tight; video honors the
9
+ // MAUDE_ASSET_MAX_VIDEO_BYTES override), content-addressed dedupe.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { existsSync, readdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ import { assetCapForCategory, sniffAssetType } from '../api.ts';
16
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
17
+
18
+ // ── Minimal valid magic-byte headers (padded so the ≥12-byte sniff has data). ──
19
+ const pad = (header: number[], len = 64): Uint8Array => {
20
+ const out = new Uint8Array(len);
21
+ out.set(header, 0);
22
+ return out;
23
+ };
24
+ const ascii = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
25
+
26
+ const MP4 = pad([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('isom')]);
27
+ const MOV = pad([0, 0, 0, 0x14, ...ascii('ftyp'), ...ascii('qt ')]);
28
+ const M4A = pad([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('M4A ')]);
29
+ const WEBM = pad([0x1a, 0x45, 0xdf, 0xa3]);
30
+ const MP3_ID3 = pad([...ascii('ID3'), 0x03, 0x00]);
31
+ const MP3_SYNC = pad([0xff, 0xfb, 0x90, 0x00]);
32
+ const WAV = pad([...ascii('RIFF'), 0, 0, 0, 0, ...ascii('WAVE')]);
33
+ const PNG = pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
34
+ const SVG = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>');
35
+
36
+ describe('video-asset / sniffAssetType (the widened type gate)', () => {
37
+ test('recognizes video containers by magic bytes', () => {
38
+ expect(sniffAssetType(MP4)).toEqual({ ext: 'mp4', category: 'video' });
39
+ expect(sniffAssetType(MOV)).toEqual({ ext: 'mov', category: 'video' });
40
+ expect(sniffAssetType(WEBM)).toEqual({ ext: 'webm', category: 'video' });
41
+ });
42
+
43
+ test('recognizes audio containers by magic bytes', () => {
44
+ expect(sniffAssetType(M4A)).toEqual({ ext: 'm4a', category: 'audio' });
45
+ expect(sniffAssetType(MP3_ID3)).toEqual({ ext: 'mp3', category: 'audio' });
46
+ expect(sniffAssetType(MP3_SYNC)).toEqual({ ext: 'mp3', category: 'audio' });
47
+ expect(sniffAssetType(WAV)).toEqual({ ext: 'wav', category: 'audio' });
48
+ });
49
+
50
+ test('images still resolve through the same superset sniff', () => {
51
+ expect(sniffAssetType(PNG)).toEqual({ ext: 'png', category: 'image' });
52
+ });
53
+
54
+ test('rejects SVG / script / text / empty (no script-bearing vector rides in)', () => {
55
+ expect(sniffAssetType(SVG)).toBeNull();
56
+ expect(sniffAssetType(new TextEncoder().encode('not media at all'))).toBeNull();
57
+ expect(sniffAssetType(new Uint8Array(0))).toBeNull();
58
+ // "RIFF" without "WAVE" (or "WEBP") must NOT match a media/image type.
59
+ expect(sniffAssetType(pad([...ascii('RIFF'), 0, 0, 0, 0, ...ascii('AVI ')]))).toBeNull();
60
+ });
61
+
62
+ test('caps: image 10 MB tight, video/audio share the larger cap', () => {
63
+ expect(assetCapForCategory('image')).toBe(10 * 1024 * 1024);
64
+ expect(assetCapForCategory('video')).toBeGreaterThan(assetCapForCategory('image'));
65
+ expect(assetCapForCategory('audio')).toBe(assetCapForCategory('video'));
66
+ });
67
+ });
68
+
69
+ describe('video-asset / POST /_api/asset (streaming endpoint)', () => {
70
+ async function withServer(
71
+ fn: (port: number, designRoot: string) => Promise<void>,
72
+ extraEnv?: Record<string, string>
73
+ ) {
74
+ const sandbox = makeSandbox();
75
+ const port = nextPort();
76
+ const proc = await bootServer(sandbox.root, port, extraEnv);
77
+ try {
78
+ await fn(port, sandbox.designRoot);
79
+ } finally {
80
+ await killProc(proc);
81
+ }
82
+ }
83
+
84
+ const postAsset = (port: number, body: Uint8Array) =>
85
+ fetch(`http://localhost:${port}/_api/asset`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/octet-stream' },
88
+ body,
89
+ });
90
+
91
+ test('writes valid mp4/mov/webm/mp3/wav/m4a with the sniffed ext', async () => {
92
+ await withServer(async (port, designRoot) => {
93
+ for (const [bytes, ext] of [
94
+ [MP4, 'mp4'],
95
+ [MOV, 'mov'],
96
+ [WEBM, 'webm'],
97
+ [MP3_ID3, 'mp3'],
98
+ [WAV, 'wav'],
99
+ [M4A, 'm4a'],
100
+ ] as const) {
101
+ const res = await postAsset(port, bytes);
102
+ expect(res.status).toBe(201);
103
+ const json = (await res.json()) as { path: string };
104
+ expect(json.path).toMatch(new RegExp(`^assets/[0-9a-f]{8}\\.${ext}$`));
105
+ expect(existsSync(join(designRoot, json.path))).toBe(true);
106
+ }
107
+ });
108
+ });
109
+
110
+ test('bytes decide the ext, never the (octet-stream) upload header', async () => {
111
+ await withServer(async (port) => {
112
+ const res = await postAsset(port, MP4);
113
+ expect(res.status).toBe(201);
114
+ const json = (await res.json()) as { path: string };
115
+ expect(json.path.endsWith('.mp4')).toBe(true);
116
+ });
117
+ });
118
+
119
+ test('rejects an SVG upload (script-bearing) with 415 even on the media route', async () => {
120
+ await withServer(async (port) => {
121
+ expect((await postAsset(port, SVG)).status).toBe(415);
122
+ });
123
+ });
124
+
125
+ test('honors the MAUDE_ASSET_MAX_VIDEO_BYTES per-file cap (streamed → 413)', async () => {
126
+ await withServer(
127
+ async (port) => {
128
+ const big = new Uint8Array(2048);
129
+ big.set([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('isom')], 0); // valid mp4 head
130
+ expect((await postAsset(port, big)).status).toBe(413);
131
+ // A small mp4 under the cap still writes.
132
+ expect((await postAsset(port, MP4)).status).toBe(201);
133
+ },
134
+ { MAUDE_ASSET_MAX_VIDEO_BYTES: '1024' }
135
+ );
136
+ });
137
+
138
+ test('identical media bytes dedupe to ONE content-addressed file', async () => {
139
+ await withServer(async (port, designRoot) => {
140
+ const a = await (await postAsset(port, MP4)).json();
141
+ const b = await (await postAsset(port, MP4)).json();
142
+ expect(a.path).toBe(b.path);
143
+ const files = readdirSync(join(designRoot, 'assets')).filter((f) => !f.startsWith('.tmp-'));
144
+ expect(files.length).toBe(1);
145
+ });
146
+ });
147
+
148
+ test('a rejected oversize upload leaves NO temp file behind (clean disk)', async () => {
149
+ await withServer(
150
+ async (port, designRoot) => {
151
+ const big = new Uint8Array(2048);
152
+ big.set([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('isom')], 0);
153
+ expect((await postAsset(port, big)).status).toBe(413);
154
+ const assetsDir = join(designRoot, 'assets');
155
+ const leftover = existsSync(assetsDir)
156
+ ? readdirSync(assetsDir).filter((f) => f.startsWith('.tmp-'))
157
+ : [];
158
+ expect(leftover).toEqual([]);
159
+ },
160
+ { MAUDE_ASSET_MAX_VIDEO_BYTES: '1024' }
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,50 @@
1
+ // video-comp-fixture — DDR-148 end-to-end build guard (CI-safe, no browser).
2
+ //
3
+ // The heavy render/scrub/export path is verified live via agent-browser + the
4
+ // determinism smoke; here we lock the CI-cheap invariant the whole feature
5
+ // rests on: a real video-comp canvas (the canonical fixture) BUILDS through the
6
+ // production canvas pipeline (buildCanvasModule) with `remotion`,
7
+ // `@remotion/transitions`, and the `fade` presentation externalised to the
8
+ // importmap (RUNTIME_PACKAGES) — never inlined (which would resolve against a
9
+ // non-existent user node_modules on an npm/marketplace install) — and with the
10
+ // <VideoComp> wrapper inlined from @maude/canvas-lib.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { readFileSync } from 'node:fs';
14
+
15
+ import { buildCanvasModule } from '../canvas-build.ts';
16
+
17
+ const FIXTURE = new URL('./fixtures/video-comp-fixture.tsx', import.meta.url).pathname;
18
+
19
+ describe('video-comp fixture — production build guard', () => {
20
+ test('builds through buildCanvasModule with the Remotion runtime externalised', async () => {
21
+ const src = readFileSync(FIXTURE, 'utf8');
22
+ const r = await buildCanvasModule(FIXTURE, src);
23
+ expect(r.js.length).toBeGreaterThan(1000);
24
+
25
+ const externals = new Set(
26
+ [...r.js.matchAll(/from\s*["']([^"'.][^"']*)["']/g)].map((m) => m[1])
27
+ );
28
+ // The comp's Remotion imports MUST stay external (importmap → pre-built
29
+ // /_canvas-runtime bundles), or an end-user install can't resolve them.
30
+ expect(externals.has('remotion')).toBe(true);
31
+ expect(externals.has('@remotion/transitions')).toBe(true);
32
+ expect(externals.has('@remotion/transitions/fade')).toBe(true);
33
+ expect(externals.has('react')).toBe(true);
34
+
35
+ // <VideoComp> (+ the seek-bridge) is inlined from @maude/canvas-lib.
36
+ expect(r.js).toContain('VideoComp');
37
+ expect(r.js).toContain('__maude_seek__');
38
+ });
39
+
40
+ test('every Remotion specifier the fixture imports is a known RUNTIME_PACKAGE', async () => {
41
+ const { RUNTIME_PACKAGES } = await import('../runtime-bundle.ts');
42
+ const known = new Set<string>(RUNTIME_PACKAGES);
43
+ const src = readFileSync(FIXTURE, 'utf8');
44
+ const imported = [...src.matchAll(/from\s*["'](@?remotion[^"']*)["']/g)].map((m) => m[1]);
45
+ expect(imported.length).toBeGreaterThan(0);
46
+ for (const spec of imported) {
47
+ expect(known.has(spec)).toBe(true);
48
+ }
49
+ });
50
+ });