@1agh/maude 0.42.0 → 0.43.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 (236) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -22
  6. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  7. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  8. package/apps/studio/bin/_video-playwright.mjs +141 -71
  9. package/apps/studio/bin/ingest-footage.sh +27 -0
  10. package/apps/studio/bin/photo-adjust.sh +163 -0
  11. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  12. package/apps/studio/bin/probe-footage.sh +28 -0
  13. package/apps/studio/bin/read-annotations.mjs +125 -2
  14. package/apps/studio/canvas-edit.ts +459 -10
  15. package/apps/studio/canvas-lib.tsx +523 -5
  16. package/apps/studio/canvas-pipeline.ts +65 -7
  17. package/apps/studio/canvas-shell.tsx +156 -16
  18. package/apps/studio/client/app.jsx +451 -34
  19. package/apps/studio/client/export-center.jsx +35 -1
  20. package/apps/studio/client/github.js +4 -0
  21. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  23. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  24. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  25. package/apps/studio/client/photo-knobs.jsx +437 -0
  26. package/apps/studio/client/styles/3-shell-maude.css +42 -0
  27. package/apps/studio/commands/edit-source-command.ts +31 -4
  28. package/apps/studio/context-menu.tsx +31 -6
  29. package/apps/studio/dist/client.bundle.js +5561 -19
  30. package/apps/studio/dist/comment-mount.js +2 -2
  31. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  32. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  33. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  34. package/apps/studio/dist/styles.css +1 -1
  35. package/apps/studio/dom-selection.ts +25 -0
  36. package/apps/studio/exporters/jobs.ts +47 -5
  37. package/apps/studio/exporters/video.ts +24 -4
  38. package/apps/studio/footage/schema.test.ts +195 -0
  39. package/apps/studio/footage/schema.ts +484 -0
  40. package/apps/studio/footage-store.ts +235 -0
  41. package/apps/studio/fs-watch.ts +10 -0
  42. package/apps/studio/git/endpoints.ts +68 -7
  43. package/apps/studio/git/service.ts +196 -20
  44. package/apps/studio/github/service.ts +67 -0
  45. package/apps/studio/handoff.ts +3 -2
  46. package/apps/studio/http.ts +173 -14
  47. package/apps/studio/input-router.tsx +10 -0
  48. package/apps/studio/inspect.ts +53 -1
  49. package/apps/studio/paths.ts +10 -0
  50. package/apps/studio/photo/filters.ts +285 -0
  51. package/apps/studio/photo/pipeline.ts +607 -0
  52. package/apps/studio/photo/schema.ts +367 -0
  53. package/apps/studio/photo-store.ts +154 -0
  54. package/apps/studio/runtime-bundle.ts +29 -6
  55. package/apps/studio/server.ts +5 -1
  56. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  57. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  58. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  59. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  60. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  61. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  62. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  63. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  64. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  65. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  66. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  67. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  68. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  69. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  70. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  71. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  72. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  73. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  74. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  75. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  81. package/apps/studio/stickers/life-style/best.png +0 -0
  82. package/apps/studio/stickers/life-style/come-on.png +0 -0
  83. package/apps/studio/stickers/life-style/cut.png +0 -0
  84. package/apps/studio/stickers/life-style/detox.png +0 -0
  85. package/apps/studio/stickers/life-style/just.png +0 -0
  86. package/apps/studio/stickers/life-style/manifest.json +68 -0
  87. package/apps/studio/stickers/life-style/mirror.png +0 -0
  88. package/apps/studio/stickers/life-style/nice.png +0 -0
  89. package/apps/studio/stickers/life-style/objects.png +0 -0
  90. package/apps/studio/stickers/life-style/ok.png +0 -0
  91. package/apps/studio/stickers/life-style/queen.png +0 -0
  92. package/apps/studio/stickers/life-style/star.png +0 -0
  93. package/apps/studio/stickers/life-style/sun.png +0 -0
  94. package/apps/studio/stickers/life-style/water.png +0 -0
  95. package/apps/studio/stickers/life-style/yeah.png +0 -0
  96. package/apps/studio/stickers/life-style/you-can.png +0 -0
  97. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  98. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  99. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  100. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  101. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  102. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  103. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  104. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  105. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  106. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  107. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  108. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  109. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  110. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  111. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  112. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  113. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  114. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  118. package/apps/studio/stickers/project-status/group-100.png +0 -0
  119. package/apps/studio/stickers/project-status/group-101.png +0 -0
  120. package/apps/studio/stickers/project-status/group-102.png +0 -0
  121. package/apps/studio/stickers/project-status/group-103.png +0 -0
  122. package/apps/studio/stickers/project-status/group-104.png +0 -0
  123. package/apps/studio/stickers/project-status/group-105.png +0 -0
  124. package/apps/studio/stickers/project-status/group-106.png +0 -0
  125. package/apps/studio/stickers/project-status/group-107.png +0 -0
  126. package/apps/studio/stickers/project-status/group-108.png +0 -0
  127. package/apps/studio/stickers/project-status/group-109.png +0 -0
  128. package/apps/studio/stickers/project-status/group-110.png +0 -0
  129. package/apps/studio/stickers/project-status/group-111.png +0 -0
  130. package/apps/studio/stickers/project-status/group-112.png +0 -0
  131. package/apps/studio/stickers/project-status/group-113.png +0 -0
  132. package/apps/studio/stickers/project-status/group-114.png +0 -0
  133. package/apps/studio/stickers/project-status/group-115.png +0 -0
  134. package/apps/studio/stickers/project-status/group-117.png +0 -0
  135. package/apps/studio/stickers/project-status/group-118.png +0 -0
  136. package/apps/studio/stickers/project-status/group-119.png +0 -0
  137. package/apps/studio/stickers/project-status/group-120.png +0 -0
  138. package/apps/studio/stickers/project-status/group-121.png +0 -0
  139. package/apps/studio/stickers/project-status/group-122.png +0 -0
  140. package/apps/studio/stickers/project-status/group-41.png +0 -0
  141. package/apps/studio/stickers/project-status/group-42.png +0 -0
  142. package/apps/studio/stickers/project-status/group-43.png +0 -0
  143. package/apps/studio/stickers/project-status/group-44.png +0 -0
  144. package/apps/studio/stickers/project-status/group-45.png +0 -0
  145. package/apps/studio/stickers/project-status/group-46.png +0 -0
  146. package/apps/studio/stickers/project-status/group-47.png +0 -0
  147. package/apps/studio/stickers/project-status/group-48.png +0 -0
  148. package/apps/studio/stickers/project-status/group-49.png +0 -0
  149. package/apps/studio/stickers/project-status/group-50.png +0 -0
  150. package/apps/studio/stickers/project-status/group-51.png +0 -0
  151. package/apps/studio/stickers/project-status/group-52.png +0 -0
  152. package/apps/studio/stickers/project-status/group-53.png +0 -0
  153. package/apps/studio/stickers/project-status/group-54.png +0 -0
  154. package/apps/studio/stickers/project-status/group-55.png +0 -0
  155. package/apps/studio/stickers/project-status/group-56.png +0 -0
  156. package/apps/studio/stickers/project-status/group-57.png +0 -0
  157. package/apps/studio/stickers/project-status/group-58.png +0 -0
  158. package/apps/studio/stickers/project-status/group-59.png +0 -0
  159. package/apps/studio/stickers/project-status/group-60.png +0 -0
  160. package/apps/studio/stickers/project-status/group-61.png +0 -0
  161. package/apps/studio/stickers/project-status/group-62.png +0 -0
  162. package/apps/studio/stickers/project-status/group-63.png +0 -0
  163. package/apps/studio/stickers/project-status/group-64.png +0 -0
  164. package/apps/studio/stickers/project-status/group-65.png +0 -0
  165. package/apps/studio/stickers/project-status/group-66.png +0 -0
  166. package/apps/studio/stickers/project-status/group-67.png +0 -0
  167. package/apps/studio/stickers/project-status/group-68.png +0 -0
  168. package/apps/studio/stickers/project-status/group-69.png +0 -0
  169. package/apps/studio/stickers/project-status/group-70.png +0 -0
  170. package/apps/studio/stickers/project-status/group-71.png +0 -0
  171. package/apps/studio/stickers/project-status/group-72.png +0 -0
  172. package/apps/studio/stickers/project-status/group-73.png +0 -0
  173. package/apps/studio/stickers/project-status/group-74.png +0 -0
  174. package/apps/studio/stickers/project-status/group-75.png +0 -0
  175. package/apps/studio/stickers/project-status/group-76.png +0 -0
  176. package/apps/studio/stickers/project-status/group-77.png +0 -0
  177. package/apps/studio/stickers/project-status/group-78.png +0 -0
  178. package/apps/studio/stickers/project-status/group-79.png +0 -0
  179. package/apps/studio/stickers/project-status/group-80.png +0 -0
  180. package/apps/studio/stickers/project-status/group-81.png +0 -0
  181. package/apps/studio/stickers/project-status/group-82.png +0 -0
  182. package/apps/studio/stickers/project-status/group-83.png +0 -0
  183. package/apps/studio/stickers/project-status/group-84.png +0 -0
  184. package/apps/studio/stickers/project-status/group-85.png +0 -0
  185. package/apps/studio/stickers/project-status/group-86.png +0 -0
  186. package/apps/studio/stickers/project-status/group-87.png +0 -0
  187. package/apps/studio/stickers/project-status/group-88.png +0 -0
  188. package/apps/studio/stickers/project-status/group-89.png +0 -0
  189. package/apps/studio/stickers/project-status/group-90.png +0 -0
  190. package/apps/studio/stickers/project-status/group-91.png +0 -0
  191. package/apps/studio/stickers/project-status/group-92.png +0 -0
  192. package/apps/studio/stickers/project-status/group-93.png +0 -0
  193. package/apps/studio/stickers/project-status/group-94.png +0 -0
  194. package/apps/studio/stickers/project-status/group-96.png +0 -0
  195. package/apps/studio/stickers/project-status/group-97.png +0 -0
  196. package/apps/studio/stickers/project-status/group-98.png +0 -0
  197. package/apps/studio/stickers/project-status/group-99.png +0 -0
  198. package/apps/studio/stickers/project-status/manifest.json +328 -0
  199. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  200. package/apps/studio/test/acp-commands.test.ts +2 -1
  201. package/apps/studio/test/asset-api.test.ts +3 -3
  202. package/apps/studio/test/canvas-edit.test.ts +17 -6
  203. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  204. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  205. package/apps/studio/test/edit-source-command.test.ts +23 -0
  206. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  207. package/apps/studio/test/footage-store.test.ts +100 -0
  208. package/apps/studio/test/git-branches.test.ts +97 -0
  209. package/apps/studio/test/github-api.test.ts +56 -1
  210. package/apps/studio/test/input-router.test.ts +33 -0
  211. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  212. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  213. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  214. package/apps/studio/test/photo-filters.test.ts +149 -0
  215. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  216. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  217. package/apps/studio/test/read-annotations.test.ts +164 -0
  218. package/apps/studio/test/shell-importmap.test.ts +49 -0
  219. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  220. package/apps/studio/test/timeline-parse.test.ts +57 -0
  221. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  222. package/apps/studio/test/undo-stack.test.ts +33 -0
  223. package/apps/studio/test/video-asset.test.ts +5 -5
  224. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  225. package/apps/studio/text-caret.ts +239 -0
  226. package/apps/studio/tool-palette.tsx +43 -20
  227. package/apps/studio/undo-stack.ts +94 -2
  228. package/apps/studio/use-annotation-resize.tsx +4 -4
  229. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  230. package/apps/studio/use-selection-set.tsx +21 -0
  231. package/apps/studio/video-comp.tsx +48 -7
  232. package/apps/studio/whats-new.json +53 -0
  233. package/cli/commands/design.mjs +34 -3
  234. package/cli/lib/gitignore-block.mjs +1 -0
  235. package/package.json +10 -10
  236. package/plugins/design/templates/_shell.html +1 -0
@@ -10,6 +10,7 @@ import {
10
10
  applyDeleteElement,
11
11
  applyInsertArtboard,
12
12
  applyInsertElement,
13
+ applyInsertElementIntoArtboard,
13
14
  applyResizeArtboard,
14
15
  CanvasEditError,
15
16
  } from '../canvas-edit.ts';
@@ -153,6 +154,51 @@ describe('canvas-edit / applyInsertElement', () => {
153
154
  });
154
155
  });
155
156
 
157
+ describe('canvas-edit / applyInsertElementIntoArtboard (empty-artboard fallback)', () => {
158
+ const emptyCanvas = [
159
+ 'export default function Demo() {',
160
+ ' return (',
161
+ ' <DesignCanvas>',
162
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
163
+ ' </DCArtboard>',
164
+ ' </DesignCanvas>',
165
+ ' );',
166
+ '}',
167
+ ].join('\n');
168
+
169
+ test("inserts a div as the artboard's only child (inside-end)", () => {
170
+ const out = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'div');
171
+ expect(out.source).toMatch(
172
+ /<DCArtboard id="home"[^>]*>[\s\S]*var\(--bg-2\)[\s\S]*<\/DCArtboard>/
173
+ );
174
+ expect(parses(out.source)).toBe(true);
175
+ expect(out.newId).toMatch(/^[0-9a-f]{8}$/);
176
+ });
177
+
178
+ test('inside-start lands the new element right after the opening tag', () => {
179
+ const out = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-start', 'text');
180
+ expect(out.source).toContain('<p style={{ margin: 0 }}>Text</p>');
181
+ expect(parses(out.source)).toBe(true);
182
+ });
183
+
184
+ test('image insert requires a contained asset src', () => {
185
+ expect(() =>
186
+ applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'image')
187
+ ).toThrow(CanvasEditError);
188
+ const ok = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'image', {
189
+ src: 'assets/ab12cd34.png',
190
+ });
191
+ expect(ok.source).toContain('src="assets/ab12cd34.png"');
192
+ expect(parses(ok.source)).toBe(true);
193
+ });
194
+
195
+ test('unknown artboard id throws', () => {
196
+ expect(() =>
197
+ applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'nope', 'inside-end', 'div')
198
+ ).toThrow(CanvasEditError);
199
+ });
200
+ });
201
+
156
202
  describe('canvas-edit / applyResizeArtboard (D4)', () => {
157
203
  const canvas = [
158
204
  'export default function Demo() {',
@@ -0,0 +1,100 @@
1
+ // footage-store — round-trip + rejection coverage (feature-footage-analysis-director).
2
+ // Uses a real temp designRoot on disk (Bun.write/Bun.file). Mirrors the photo
3
+ // store's contract: valid analysis + EDL persist and read back; invalid input
4
+ // returns a discriminated {ok:false,status} (never throws); traversal params in
5
+ // the asset/slug are rejected by construction.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
8
+ import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+ import type { Context } from '../context.ts';
12
+ import { assetSha8, createFootageStore, edlSlug } from '../footage-store.ts';
13
+
14
+ let root: string;
15
+ let store: ReturnType<typeof createFootageStore>;
16
+
17
+ beforeAll(() => {
18
+ root = mkdtempSync(path.join(tmpdir(), 'footage-store-'));
19
+ const designRoot = path.join(root, '.design');
20
+ // Only the fields footage-store touches are needed.
21
+ store = createFootageStore({ paths: { designRoot } } as unknown as Context);
22
+ });
23
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
24
+
25
+ describe('assetSha8 / edlSlug extraction', () => {
26
+ test('accepts the sha8 in every form', () => {
27
+ for (const p of ['assets/a1b2c3d4.mp4', '/assets/a1b2c3d4.png', 'a1b2c3d4.mov', 'a1b2c3d4'])
28
+ expect(assetSha8(p)).toBe('a1b2c3d4');
29
+ });
30
+ test('rejects traversal / non-hex', () => {
31
+ for (const p of ['../x', 'assets/../x', 'not-hex!', '', null]) expect(assetSha8(p)).toBeNull();
32
+ });
33
+ test('slug accepts kebab, rejects traversal / dots', () => {
34
+ expect(edlSlug('alligators-reel')).toBe('alligators-reel');
35
+ for (const s of ['../evil', 'has.dot', 'UPPER', '/abs', '']) expect(edlSlug(s)).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe('analysis round-trip', () => {
40
+ test('saves a valid analysis and reads it back', async () => {
41
+ const res = await store.saveAnalysis('assets/a1b2c3d4.mp4', {
42
+ asset: 'assets/a1b2c3d4.mp4',
43
+ durationSec: 10,
44
+ shots: [{ start: 0, end: 3, usable: true, quality: 0.8 }],
45
+ });
46
+ expect(res.ok).toBe(true);
47
+ if (res.ok) expect(res.path).toBe('assets/a1b2c3d4.footage.json');
48
+ const back = await store.getAnalysis('a1b2c3d4');
49
+ expect(back?.shots?.[0].quality).toBe(0.8);
50
+ expect(back?.version).toBe(1);
51
+ });
52
+
53
+ test('rejects an invalid analysis with a 400 (never throws)', async () => {
54
+ const res = await store.saveAnalysis('assets/a1b2c3d4.mp4', { shots: [{ start: 5, end: 5 }] });
55
+ expect(res.ok).toBe(false);
56
+ if (!res.ok) expect(res.status).toBe(400);
57
+ });
58
+
59
+ test('rejects a bad asset param with a 400', async () => {
60
+ const res = await store.saveAnalysis('../evil', {});
61
+ expect(res.ok).toBe(false);
62
+ if (!res.ok) expect(res.status).toBe(400);
63
+ });
64
+
65
+ test('getAnalysis returns null for an absent sidecar', async () => {
66
+ expect(await store.getAnalysis('ffffffff')).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe('EDL round-trip', () => {
71
+ test('saves a valid EDL under <slug>.edl.json and reads it back', async () => {
72
+ const res = await store.saveEdl('alligators-reel', {
73
+ title: 'Reel',
74
+ fps: 30,
75
+ width: 1920,
76
+ height: 1080,
77
+ beats: [{ clip: 'assets/a1b2c3d4.mp4', startSec: 0, durationFrames: 60, name: 'intro' }],
78
+ });
79
+ expect(res.ok).toBe(true);
80
+ if (res.ok) expect(res.path).toBe('alligators-reel.edl.json');
81
+ const back = await store.getEdl('alligators-reel');
82
+ expect(back?.beats?.[0].name).toBe('intro');
83
+ expect(back?.version).toBe(1);
84
+ });
85
+
86
+ test('rejects an EDL with an unbundled transition', async () => {
87
+ const res = await store.saveEdl('bad-reel', {
88
+ beats: [
89
+ {
90
+ clip: 'assets/a1b2c3d4.mp4',
91
+ startSec: 0,
92
+ durationFrames: 30,
93
+ transition: { presentation: 'zoom', frames: 5 },
94
+ },
95
+ ],
96
+ });
97
+ expect(res.ok).toBe(false);
98
+ if (!res.ok) expect(res.status).toBe(400);
99
+ });
100
+ });
@@ -14,6 +14,8 @@ import {
14
14
  gitFetchRemote,
15
15
  gitFoldDraft,
16
16
  gitListBranches,
17
+ gitPull,
18
+ gitPush,
17
19
  remoteAheadBehind,
18
20
  } from '../git/service.ts';
19
21
 
@@ -111,6 +113,30 @@ test('fold: merges the draft into the Shared version locally; a tokenless publis
111
113
  expect((await gitListBranches(dir)).some((b) => b.name === 'nav')).toBe(true);
112
114
  });
113
115
 
116
+ test('fold: a GitHub remote takes the PR path — pushes the draft, never locally merges main (DDR-162)', async () => {
117
+ // isGitHubRemote(github.com) → PR flow: publish the DRAFT branch (fails here — no
118
+ // real access) and NEVER merge/modify the local Shared version. `false` as the ssh
119
+ // command fails the transport instantly, so there's no network to github.com.
120
+ const prevSsh = process.env.GIT_SSH_COMMAND;
121
+ process.env.GIT_SSH_COMMAND = 'false';
122
+ const shared = (await gitListBranches(dir)).find((b) => b.current)?.name as string;
123
+ sh(['remote', 'add', 'origin', 'git@github.com:fake-org/fake-repo.git']);
124
+ await gitCreateBranch(dir, 'nav'); // now on 'nav'
125
+ writeFileSync(join(dir, 'b.txt'), 'draft work\n');
126
+ sh(['add', '.']);
127
+ sh(['commit', '-q', '-m', 'draft work']);
128
+ const r = await gitFoldDraft(dir, 'nav', undefined, {});
129
+ process.env.GIT_SSH_COMMAND = prevSsh ?? '';
130
+ // The draft push failed (fake repo) → not ok, and NOT the iso transport error…
131
+ expect(r.ok).toBe(false);
132
+ expect(r.error || '').not.toMatch(/unrecognized transport/i);
133
+ // …but crucially the Shared version was NEVER locally merged/modified…
134
+ sh(['checkout', shared]);
135
+ expect(existsSync(join(dir, 'b.txt'))).toBe(false);
136
+ // …and the draft still exists (nothing destroyed on a failed publish).
137
+ expect((await gitListBranches(dir)).some((b) => b.name === 'nav')).toBe(true);
138
+ });
139
+
114
140
  // ── remote drafts (phase: surface remote branches) ───────────────────────────
115
141
 
116
142
  /** Stand up a bare "remote" with main + a teammate draft, clone it locally (system
@@ -197,6 +223,77 @@ test('an ssh remote routes to system git, never the iso transport error', async
197
223
  expect(r.error || '').not.toMatch(/unrecognized transport/i); // didn't fall into iso
198
224
  });
199
225
 
226
+ // ── publish / get-latest over ssh (DDR-131/DDR-133 parity — the gap that shipped) ──
227
+ // gitFetchRemote was hardened for ssh; gitPush/gitPull historically routed on the
228
+ // USE_SYSTEM_GIT env flag only (default off) and fell into iso → "unrecognized
229
+ // transport protocol: ssh". These prove the write paths now take the same gate.
230
+
231
+ test('gitPush: an ssh remote routes to system git, never the iso transport error', async () => {
232
+ process.env.GIT_SSH_COMMAND =
233
+ 'ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no';
234
+ sh(['remote', 'add', 'origin', 'git@nonexistent.invalid:team/app.git']);
235
+ const r = await gitPush(dir, undefined, {});
236
+ process.env.GIT_SSH_COMMAND = '';
237
+ expect(r.ok).toBe(false);
238
+ expect(r.authRequired).toBeFalsy(); // an ssh failure is not a GitHub sign-in prompt
239
+ expect(r.error || '').not.toMatch(/unrecognized transport/i); // didn't fall into iso
240
+ });
241
+
242
+ test('gitPull: an ssh remote routes to system git, never the iso transport error', async () => {
243
+ process.env.GIT_SSH_COMMAND =
244
+ 'ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no';
245
+ sh(['remote', 'add', 'origin', 'git@nonexistent.invalid:team/app.git']);
246
+ const r = await gitPull(dir, undefined, {});
247
+ process.env.GIT_SSH_COMMAND = '';
248
+ expect(r.ok).toBe(false);
249
+ expect(r.authRequired).toBeFalsy();
250
+ expect(r.error || '').not.toMatch(/unrecognized transport/i);
251
+ });
252
+
253
+ test('SECURITY: gitPush refuses an unsafe (ext::) remote — no spawn, no RCE', async () => {
254
+ // A poisoned .git/config remote — must be refused BEFORE any git spawn (the payload
255
+ // never runs); classifyRemoteUrl reads the url via iso getConfig, not the git binary.
256
+ const sentinel = join(dir, 'PWNED_PUSH');
257
+ sh(['remote', 'add', 'origin', `ext::sh -c "touch ${sentinel}"`]);
258
+ const r = await gitPush(dir, 'tok_should_not_be_used', {});
259
+ expect(r.ok).toBe(false);
260
+ expect(r.error).toMatch(/github\.com/i); // "can only sync github.com…"
261
+ expect(existsSync(sentinel)).toBe(false); // payload NEVER executed
262
+ });
263
+
264
+ test('SECURITY: gitPush does not send the GitHub token to a non-GitHub HTTPS host', async () => {
265
+ sh(['remote', 'add', 'origin', 'https://evil.example.com/repo.git']);
266
+ const r = await gitPush(dir, 'tok_secret', {});
267
+ expect(r.ok).toBe(false);
268
+ expect(r.error).toMatch(/github\.com/i); // refused by policy, token never attached
269
+ });
270
+
271
+ test('SECURITY: a backslash-@ host-confusion remote is refused, PAT never attached (F1)', async () => {
272
+ // `https://github.com\@evil` — new URL() parses host=github.com (backslash → '/'),
273
+ // but git/curl dials `evil` (backslash = path/userinfo char). The token-host allowlist
274
+ // must reject the parser differential and NOT lend the PAT — on every token path:
275
+ // explicit fetch, the unattended ahead/behind probe (the zero-click trigger), and push.
276
+ sh(['remote', 'add', 'origin', 'https://github.com\\@evil.example/x.git']);
277
+ const f = await gitFetchRemote(dir, 'tok_secret');
278
+ expect(f.ok).toBe(false);
279
+ expect(f.error).toMatch(/github\.com/i); // refused by policy — token never attached
280
+ const ab = await remoteAheadBehind(dir, 'tok_secret');
281
+ expect(ab).toEqual({ ahead: 0, behind: 0 }); // the unattended probe refuses too (0/0, no fetch)
282
+ const p = await gitPush(dir, 'tok_secret', {});
283
+ expect(p.ok).toBe(false);
284
+ expect(p.error).toMatch(/github\.com/i);
285
+ });
286
+
287
+ test('SECURITY: the unattended probe refuses a repo with a url.insteadOf rewrite (verify re-review)', async () => {
288
+ // A clean github `origin.url` (passes isTrustedTokenHost) + a poisoned
289
+ // `url.<attacker>.insteadOf` that redirects github.com → attacker (only the system
290
+ // engine honors it). git would dial the attacker; the zero-click probe must refuse.
291
+ sh(['remote', 'add', 'origin', 'https://github.com/owner/repo.git']);
292
+ sh(['config', '--local', 'url.https://attacker.example/.insteadOf', 'https://github.com/']);
293
+ const ab = await remoteAheadBehind(dir, 'tok_secret');
294
+ expect(ab).toEqual({ ahead: 0, behind: 0 });
295
+ });
296
+
200
297
  // ── transport-injection hardening (adversarial review of 75a2f0d) ────────────
201
298
 
202
299
  test('SECURITY: refuses to fetch an `ext::` (command-executing) remote — no spawn, no RCE', async () => {
@@ -18,6 +18,7 @@ import git from 'isomorphic-git';
18
18
  import type { Context } from '../context.ts';
19
19
  import { createGitHubEndpoints, __testing as epTesting } from '../github/endpoints.ts';
20
20
  import {
21
+ createPullRequest,
21
22
  createRepo,
22
23
  GitHubApiError,
23
24
  getIdentity,
@@ -98,6 +99,59 @@ describe('GitHub REST request construction', () => {
98
99
  expect((err as GitHubApiError).message).toMatch(/already have a project with that name/i);
99
100
  });
100
101
 
102
+ test('createPullRequest POSTs title/head/base/body and shapes the result', async () => {
103
+ const calls = stubFetch(() =>
104
+ json({ number: 12, html_url: 'https://github.com/octocat/acme/pull/12' }, 201)
105
+ );
106
+ const pr = await createPullRequest('t', 'octocat', 'acme', {
107
+ head: 'nav-redesign',
108
+ base: 'main',
109
+ title: 'Add draft',
110
+ body: 'hi',
111
+ });
112
+ expect(pr).toEqual({ number: 12, html_url: 'https://github.com/octocat/acme/pull/12' });
113
+ expect(calls[0].url).toBe('https://api.github.com/repos/octocat/acme/pulls');
114
+ expect(calls[0].init?.method).toBe('POST');
115
+ expect(JSON.parse(calls[0].init?.body as string)).toEqual({
116
+ title: 'Add draft',
117
+ head: 'nav-redesign',
118
+ base: 'main',
119
+ body: 'hi',
120
+ });
121
+ });
122
+
123
+ test('createPullRequest recovers the existing PR link on a 422 (already exists)', async () => {
124
+ const calls = stubFetch((_url, init) =>
125
+ init?.method === 'POST'
126
+ ? json({ message: 'A pull request already exists for octocat:nav.' }, 422)
127
+ : json([{ number: 7, html_url: 'https://github.com/octocat/acme/pull/7' }])
128
+ );
129
+ const pr = await createPullRequest('t', 'octocat', 'acme', {
130
+ head: 'nav',
131
+ base: 'main',
132
+ title: 'x',
133
+ });
134
+ expect(pr).toEqual({ number: 7, html_url: 'https://github.com/octocat/acme/pull/7' });
135
+ // the recovery GET filters by head=owner:branch + base + state=open
136
+ expect(calls[1].url).toContain('head=octocat%3Anav');
137
+ expect(calls[1].url).toContain('base=main');
138
+ expect(calls[1].url).toContain('state=open');
139
+ });
140
+
141
+ test('createPullRequest maps a 422 with no existing PR to a "nothing new" message', async () => {
142
+ stubFetch((_url, init) =>
143
+ init?.method === 'POST' ? json({ message: 'No commits between main and nav' }, 422) : json([])
144
+ );
145
+ const err = await createPullRequest('t', 'octocat', 'acme', {
146
+ head: 'nav',
147
+ base: 'main',
148
+ title: 'x',
149
+ }).catch((e) => e);
150
+ expect(err).toBeInstanceOf(GitHubApiError);
151
+ expect((err as GitHubApiError).status).toBe(422);
152
+ expect((err as GitHubApiError).message).toMatch(/nothing new to add/i);
153
+ });
154
+
101
155
  test('inviteCollaborator PUTs permission=push, 201 = invited', async () => {
102
156
  const calls = stubFetch(() => json({ id: 1 }, 201));
103
157
  const r = await inviteCollaborator('t', 'octocat', 'acme', 'anna');
@@ -242,7 +296,8 @@ describe('token bridge fetch', () => {
242
296
  const calls = stubFetch(() => new Response('gho_secret\n', { status: 200 }));
243
297
  expect(tokenBridgeAvailable()).toBe(true);
244
298
  expect(await getGithubToken()).toBe('gho_secret');
245
- expect((calls[0].init?.headers as Record<string, string>)['X-Maude-Token-Key']).toBe('k123');
299
+ const headers = calls[0].init?.headers as Record<string, string>;
300
+ expect(headers['X-Maude-Token-Key']).toBe('k123');
246
301
  });
247
302
 
248
303
  test('returns null on 404 (not signed in) and 403 (bad key)', async () => {
@@ -348,6 +348,39 @@ describe('input-router / isEditableTarget', () => {
348
348
  const el = { tagName: 'DIV', isContentEditable: true } as HTMLElement;
349
349
  expect(isEditableTarget(el)).toBe(true);
350
350
  });
351
+
352
+ // Dogfood fix — the reported bug: typing "R" while editing in-canvas
353
+ // element text (canvas-shell.tsx's contenteditable="plaintext-only") fired
354
+ // the Rectangle tool shortcut. `.isContentEditable` SHOULD reflect
355
+ // "plaintext-only" per spec, but engine/version inconsistencies mean it
356
+ // can't be the only check — these lock in the raw-attribute fallback that
357
+ // now makes isEditableTarget resilient regardless.
358
+ test('contenteditable="plaintext-only" (isContentEditable false or unreliable) → true', () => {
359
+ const el = {
360
+ tagName: 'DIV',
361
+ isContentEditable: false,
362
+ getAttribute: (name: string) => (name === 'contenteditable' ? 'plaintext-only' : null),
363
+ } as unknown as HTMLElement;
364
+ expect(isEditableTarget(el)).toBe(true);
365
+ });
366
+
367
+ test('bare contenteditable="" attribute → true', () => {
368
+ const el = {
369
+ tagName: 'DIV',
370
+ isContentEditable: false,
371
+ getAttribute: (name: string) => (name === 'contenteditable' ? '' : null),
372
+ } as unknown as HTMLElement;
373
+ expect(isEditableTarget(el)).toBe(true);
374
+ });
375
+
376
+ test('plain div WITH a getAttribute method (real-element shape) still → false', () => {
377
+ const el = {
378
+ tagName: 'DIV',
379
+ isContentEditable: false,
380
+ getAttribute: () => null,
381
+ } as unknown as HTMLElement;
382
+ expect(isEditableTarget(el)).toBe(false);
383
+ });
351
384
  });
352
385
 
353
386
  // T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
@@ -0,0 +1,53 @@
1
+ // inspect-script-syntax.test.ts — regression guard for a real bug hit while
2
+ // adding the photo-editor's background-removal busy shimmer: INSPECTOR_SCRIPT
3
+ // (inspect.ts) is itself a giant template literal, so a stray backtick in an
4
+ // inline comment silently truncates/reopens it — tsc DOES catch backtick
5
+ // imbalance (it breaks the outer .ts file's own syntax), but nothing catches
6
+ // any OTHER malformed-JS mistake inside the injected string (e.g. a stray
7
+ // unescaped quote, mismatched braces in the CSS array). This test extracts the
8
+ // actual injected <script> body and syntax-checks it directly.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { join } from 'node:path';
12
+
13
+ import { type Context, createBus } from '../context.ts';
14
+ import { createInspect } from '../inspect.ts';
15
+ import { makeSandbox } from './_helpers.ts';
16
+
17
+ function mkCtx(root: string, designRoot: string): Context {
18
+ return {
19
+ cfg: {} as Context['cfg'],
20
+ projectLabel: 'test',
21
+ bus: createBus(),
22
+ paths: {
23
+ repoRoot: root,
24
+ designRel: '.design',
25
+ designRoot,
26
+ serverInfoFile: join(designRoot, '_server.json'),
27
+ activeFile: join(designRoot, '_active.json'),
28
+ commentsDir: join(designRoot, '_comments'),
29
+ canvasStateDir: join(designRoot, '_canvas-state'),
30
+ historyDir: join(designRoot, '_history'),
31
+ tokensUrlRel: '',
32
+ systemDirRel: 'system',
33
+ },
34
+ };
35
+ }
36
+
37
+ describe('injectInspector — injected <script> is syntactically valid JS', () => {
38
+ test('the extracted script body compiles with no SyntaxError', () => {
39
+ const { root, designRoot } = makeSandbox();
40
+ const inspect = createInspect(mkCtx(root, designRoot), async () => []);
41
+ const out = inspect.injectInspector('<html><body></body></html>');
42
+
43
+ const scripts = [...out.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
44
+ expect(scripts.length).toBeGreaterThan(0);
45
+
46
+ for (const body of scripts) {
47
+ // `new Function` only PARSES the body (no window/document access at
48
+ // construction time) — throws SyntaxError on malformed JS, exactly the
49
+ // class of bug a stray backtick / unbalanced brace produces.
50
+ expect(() => new Function(body)).not.toThrow();
51
+ }
52
+ });
53
+ });
@@ -0,0 +1,61 @@
1
+ // photo-canvas-bundle.test.ts — the automated guard for the lazy-bundle
2
+ // acceptance criterion + BUILDER's flagged top risk (feature-photo-editor).
3
+ //
4
+ // A canvas that uses <PhotoLayer> with a NEUTRAL edit must pay ZERO pixi.js cost:
5
+ // the ~500 KB pixi runtime must stay behind a LAZY runtime `import("pixi.js")`,
6
+ // never an eager top-level `import ... from "pixi.js"`. Canvas builds run with
7
+ // `splitting: false`, so this is fragile — a future refactor that turns the
8
+ // compositor's pixi import back into a static import (or statically imports
9
+ // pipeline.ts from canvas-lib) would silently re-break it. This test bundles a
10
+ // real canvas via the actual dev-server pipeline and inspects the output.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { buildCanvasModule } from '../canvas-build.ts';
18
+
19
+ const CANVAS = `
20
+ import { DesignCanvas, DCArtboard, PhotoLayer } from '@maude/canvas-lib';
21
+ export default function C() {
22
+ return (
23
+ <DesignCanvas>
24
+ <DCArtboard id="a" width={200} height={200}>
25
+ <PhotoLayer source="assets/aaaa1111.png" edit={{}} width={120} height={120} alt="photo" />
26
+ </DCArtboard>
27
+ </DesignCanvas>
28
+ );
29
+ }
30
+ `;
31
+
32
+ async function buildCanvas(source: string): Promise<string> {
33
+ const dir = mkdtempSync(join(tmpdir(), 'maude-photo-bundle-'));
34
+ const abs = join(dir, 'probe.tsx');
35
+ writeFileSync(abs, source);
36
+ try {
37
+ const { js } = await buildCanvasModule(abs, source, {});
38
+ return js;
39
+ } finally {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ }
43
+
44
+ describe('lazy-bundle guarantee — <PhotoLayer> keeps pixi out of the eager graph', () => {
45
+ test('an unedited PhotoLayer canvas has NO eager `from "pixi.js"` import', async () => {
46
+ const js = await buildCanvas(CANVAS);
47
+ const eager = [...js.matchAll(/import\s[^;]*?from\s*["']pixi\.js["']/g)];
48
+ expect(eager.length).toBe(0);
49
+ });
50
+
51
+ test('pixi is reachable only via a lazy runtime import("pixi.js")', async () => {
52
+ const js = await buildCanvas(CANVAS);
53
+ expect(/import\(\s*["']pixi\.js["']\s*\)/.test(js)).toBe(true);
54
+ });
55
+
56
+ test('the canvas still bundles PhotoLayer itself (sanity — the import resolved)', async () => {
57
+ const js = await buildCanvas(CANVAS);
58
+ // isDefaultEdit is the schema helper <PhotoLayer> calls on the render path.
59
+ expect(js.includes('isDefaultEdit') || js.includes('PhotoLayer')).toBe(true);
60
+ });
61
+ });