@1agh/maude 0.42.0 → 0.44.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 (332) 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/acp/bridge.ts +44 -10
  4. package/apps/studio/acp/env.ts +23 -0
  5. package/apps/studio/acp/login-state.ts +263 -0
  6. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  7. package/apps/studio/acp/probe.ts +152 -1
  8. package/apps/studio/annotations-layer.tsx +1451 -324
  9. package/apps/studio/annotations-model.ts +58 -0
  10. package/apps/studio/api.ts +568 -24
  11. package/apps/studio/bin/_audio-search.mjs +140 -0
  12. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  13. package/apps/studio/bin/_import-asset.mjs +815 -0
  14. package/apps/studio/bin/_import-brand.mjs +635 -0
  15. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  16. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  17. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  18. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  19. package/apps/studio/bin/_transcribe.mjs +419 -0
  20. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  21. package/apps/studio/bin/_video-playwright.mjs +141 -71
  22. package/apps/studio/bin/audio-search.sh +28 -0
  23. package/apps/studio/bin/generate.sh +154 -0
  24. package/apps/studio/bin/import-asset.sh +34 -0
  25. package/apps/studio/bin/import-brand.sh +29 -0
  26. package/apps/studio/bin/import-tokens.sh +33 -0
  27. package/apps/studio/bin/ingest-footage.sh +27 -0
  28. package/apps/studio/bin/photo-adjust.sh +163 -0
  29. package/apps/studio/bin/photo-bg-remove.sh +258 -0
  30. package/apps/studio/bin/probe-footage.sh +28 -0
  31. package/apps/studio/bin/read-annotations.mjs +125 -2
  32. package/apps/studio/bin/transcribe.sh +39 -0
  33. package/apps/studio/canvas-edit.ts +656 -12
  34. package/apps/studio/canvas-lib.tsx +637 -10
  35. package/apps/studio/canvas-pipeline.ts +65 -7
  36. package/apps/studio/canvas-shell.tsx +156 -16
  37. package/apps/studio/client/app.jsx +2385 -699
  38. package/apps/studio/client/export-center.jsx +35 -1
  39. package/apps/studio/client/generate-dialog.jsx +352 -0
  40. package/apps/studio/client/github.js +18 -0
  41. package/apps/studio/client/inspector-controls.jsx +781 -0
  42. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  43. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  44. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  45. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  46. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  47. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  48. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  49. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  50. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  51. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  52. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  53. package/apps/studio/client/photo-knobs.jsx +432 -0
  54. package/apps/studio/client/styles/3-shell-maude.css +376 -28
  55. package/apps/studio/client/styles/4-components.css +223 -0
  56. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  57. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  58. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  59. package/apps/studio/commands/edit-source-command.ts +31 -4
  60. package/apps/studio/config.schema.json +70 -0
  61. package/apps/studio/context-menu.tsx +42 -8
  62. package/apps/studio/context.ts +16 -0
  63. package/apps/studio/design-setup-readiness.ts +115 -0
  64. package/apps/studio/dist/client.bundle.js +5572 -21
  65. package/apps/studio/dist/comment-mount.js +2 -2
  66. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  67. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  68. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  69. package/apps/studio/dist/styles.css +1 -1
  70. package/apps/studio/dom-selection.ts +25 -0
  71. package/apps/studio/draw/palette.ts +34 -0
  72. package/apps/studio/exporters/jobs.ts +47 -5
  73. package/apps/studio/exporters/video.ts +24 -4
  74. package/apps/studio/footage/schema.test.ts +287 -0
  75. package/apps/studio/footage/schema.ts +662 -0
  76. package/apps/studio/footage-store.ts +235 -0
  77. package/apps/studio/fs-watch.ts +10 -0
  78. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  79. package/apps/studio/generation/adapters/gemini.ts +542 -0
  80. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  81. package/apps/studio/generation/adapters/groq.ts +247 -0
  82. package/apps/studio/generation/audio-library.test.ts +95 -0
  83. package/apps/studio/generation/audio-library.ts +156 -0
  84. package/apps/studio/generation/captions.test.ts +78 -0
  85. package/apps/studio/generation/captions.ts +132 -0
  86. package/apps/studio/generation/download.ts +76 -0
  87. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  88. package/apps/studio/generation/gemini.test.ts +350 -0
  89. package/apps/studio/generation/jobs.test.ts +128 -0
  90. package/apps/studio/generation/jobs.ts +243 -0
  91. package/apps/studio/generation/keys.test.ts +75 -0
  92. package/apps/studio/generation/keys.ts +162 -0
  93. package/apps/studio/generation/prefs.test.ts +95 -0
  94. package/apps/studio/generation/prefs.ts +82 -0
  95. package/apps/studio/generation/registry.ts +88 -0
  96. package/apps/studio/generation/types.test.ts +123 -0
  97. package/apps/studio/generation/types.ts +266 -0
  98. package/apps/studio/generation/whisper-models.test.ts +152 -0
  99. package/apps/studio/generation/whisper-models.ts +257 -0
  100. package/apps/studio/generation/whisper-spike-results.md +61 -0
  101. package/apps/studio/git/endpoints.ts +68 -7
  102. package/apps/studio/git/service.ts +199 -21
  103. package/apps/studio/github/service.ts +67 -0
  104. package/apps/studio/handoff.ts +3 -2
  105. package/apps/studio/http.ts +1039 -22
  106. package/apps/studio/input-router.tsx +10 -0
  107. package/apps/studio/inspect.ts +53 -1
  108. package/apps/studio/media/intro.mp4 +0 -0
  109. package/apps/studio/media-commit-chain.ts +119 -0
  110. package/apps/studio/paths.ts +20 -0
  111. package/apps/studio/photo/filters.ts +285 -0
  112. package/apps/studio/photo/pipeline.ts +607 -0
  113. package/apps/studio/photo/schema.ts +367 -0
  114. package/apps/studio/photo-store.ts +154 -0
  115. package/apps/studio/readiness.ts +108 -20
  116. package/apps/studio/runtime-bundle.ts +29 -6
  117. package/apps/studio/scaffold-design.ts +1145 -0
  118. package/apps/studio/server.ts +18 -2
  119. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  120. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  121. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  122. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  123. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  124. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  125. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  126. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  127. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  128. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  129. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  130. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  131. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  132. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  133. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  134. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  135. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  136. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  137. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  138. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  139. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  140. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  141. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  142. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  143. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  144. package/apps/studio/stickers/life-style/best.png +0 -0
  145. package/apps/studio/stickers/life-style/come-on.png +0 -0
  146. package/apps/studio/stickers/life-style/cut.png +0 -0
  147. package/apps/studio/stickers/life-style/detox.png +0 -0
  148. package/apps/studio/stickers/life-style/just.png +0 -0
  149. package/apps/studio/stickers/life-style/manifest.json +68 -0
  150. package/apps/studio/stickers/life-style/mirror.png +0 -0
  151. package/apps/studio/stickers/life-style/nice.png +0 -0
  152. package/apps/studio/stickers/life-style/objects.png +0 -0
  153. package/apps/studio/stickers/life-style/ok.png +0 -0
  154. package/apps/studio/stickers/life-style/queen.png +0 -0
  155. package/apps/studio/stickers/life-style/star.png +0 -0
  156. package/apps/studio/stickers/life-style/sun.png +0 -0
  157. package/apps/studio/stickers/life-style/water.png +0 -0
  158. package/apps/studio/stickers/life-style/yeah.png +0 -0
  159. package/apps/studio/stickers/life-style/you-can.png +0 -0
  160. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  161. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  162. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  163. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  164. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  165. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  166. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  167. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  168. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  169. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  170. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  171. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  172. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  173. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  174. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  175. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  176. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  177. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  178. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  179. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  180. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  181. package/apps/studio/stickers/project-status/group-100.png +0 -0
  182. package/apps/studio/stickers/project-status/group-101.png +0 -0
  183. package/apps/studio/stickers/project-status/group-102.png +0 -0
  184. package/apps/studio/stickers/project-status/group-103.png +0 -0
  185. package/apps/studio/stickers/project-status/group-104.png +0 -0
  186. package/apps/studio/stickers/project-status/group-105.png +0 -0
  187. package/apps/studio/stickers/project-status/group-106.png +0 -0
  188. package/apps/studio/stickers/project-status/group-107.png +0 -0
  189. package/apps/studio/stickers/project-status/group-108.png +0 -0
  190. package/apps/studio/stickers/project-status/group-109.png +0 -0
  191. package/apps/studio/stickers/project-status/group-110.png +0 -0
  192. package/apps/studio/stickers/project-status/group-111.png +0 -0
  193. package/apps/studio/stickers/project-status/group-112.png +0 -0
  194. package/apps/studio/stickers/project-status/group-113.png +0 -0
  195. package/apps/studio/stickers/project-status/group-114.png +0 -0
  196. package/apps/studio/stickers/project-status/group-115.png +0 -0
  197. package/apps/studio/stickers/project-status/group-117.png +0 -0
  198. package/apps/studio/stickers/project-status/group-118.png +0 -0
  199. package/apps/studio/stickers/project-status/group-119.png +0 -0
  200. package/apps/studio/stickers/project-status/group-120.png +0 -0
  201. package/apps/studio/stickers/project-status/group-121.png +0 -0
  202. package/apps/studio/stickers/project-status/group-122.png +0 -0
  203. package/apps/studio/stickers/project-status/group-41.png +0 -0
  204. package/apps/studio/stickers/project-status/group-42.png +0 -0
  205. package/apps/studio/stickers/project-status/group-43.png +0 -0
  206. package/apps/studio/stickers/project-status/group-44.png +0 -0
  207. package/apps/studio/stickers/project-status/group-45.png +0 -0
  208. package/apps/studio/stickers/project-status/group-46.png +0 -0
  209. package/apps/studio/stickers/project-status/group-47.png +0 -0
  210. package/apps/studio/stickers/project-status/group-48.png +0 -0
  211. package/apps/studio/stickers/project-status/group-49.png +0 -0
  212. package/apps/studio/stickers/project-status/group-50.png +0 -0
  213. package/apps/studio/stickers/project-status/group-51.png +0 -0
  214. package/apps/studio/stickers/project-status/group-52.png +0 -0
  215. package/apps/studio/stickers/project-status/group-53.png +0 -0
  216. package/apps/studio/stickers/project-status/group-54.png +0 -0
  217. package/apps/studio/stickers/project-status/group-55.png +0 -0
  218. package/apps/studio/stickers/project-status/group-56.png +0 -0
  219. package/apps/studio/stickers/project-status/group-57.png +0 -0
  220. package/apps/studio/stickers/project-status/group-58.png +0 -0
  221. package/apps/studio/stickers/project-status/group-59.png +0 -0
  222. package/apps/studio/stickers/project-status/group-60.png +0 -0
  223. package/apps/studio/stickers/project-status/group-61.png +0 -0
  224. package/apps/studio/stickers/project-status/group-62.png +0 -0
  225. package/apps/studio/stickers/project-status/group-63.png +0 -0
  226. package/apps/studio/stickers/project-status/group-64.png +0 -0
  227. package/apps/studio/stickers/project-status/group-65.png +0 -0
  228. package/apps/studio/stickers/project-status/group-66.png +0 -0
  229. package/apps/studio/stickers/project-status/group-67.png +0 -0
  230. package/apps/studio/stickers/project-status/group-68.png +0 -0
  231. package/apps/studio/stickers/project-status/group-69.png +0 -0
  232. package/apps/studio/stickers/project-status/group-70.png +0 -0
  233. package/apps/studio/stickers/project-status/group-71.png +0 -0
  234. package/apps/studio/stickers/project-status/group-72.png +0 -0
  235. package/apps/studio/stickers/project-status/group-73.png +0 -0
  236. package/apps/studio/stickers/project-status/group-74.png +0 -0
  237. package/apps/studio/stickers/project-status/group-75.png +0 -0
  238. package/apps/studio/stickers/project-status/group-76.png +0 -0
  239. package/apps/studio/stickers/project-status/group-77.png +0 -0
  240. package/apps/studio/stickers/project-status/group-78.png +0 -0
  241. package/apps/studio/stickers/project-status/group-79.png +0 -0
  242. package/apps/studio/stickers/project-status/group-80.png +0 -0
  243. package/apps/studio/stickers/project-status/group-81.png +0 -0
  244. package/apps/studio/stickers/project-status/group-82.png +0 -0
  245. package/apps/studio/stickers/project-status/group-83.png +0 -0
  246. package/apps/studio/stickers/project-status/group-84.png +0 -0
  247. package/apps/studio/stickers/project-status/group-85.png +0 -0
  248. package/apps/studio/stickers/project-status/group-86.png +0 -0
  249. package/apps/studio/stickers/project-status/group-87.png +0 -0
  250. package/apps/studio/stickers/project-status/group-88.png +0 -0
  251. package/apps/studio/stickers/project-status/group-89.png +0 -0
  252. package/apps/studio/stickers/project-status/group-90.png +0 -0
  253. package/apps/studio/stickers/project-status/group-91.png +0 -0
  254. package/apps/studio/stickers/project-status/group-92.png +0 -0
  255. package/apps/studio/stickers/project-status/group-93.png +0 -0
  256. package/apps/studio/stickers/project-status/group-94.png +0 -0
  257. package/apps/studio/stickers/project-status/group-96.png +0 -0
  258. package/apps/studio/stickers/project-status/group-97.png +0 -0
  259. package/apps/studio/stickers/project-status/group-98.png +0 -0
  260. package/apps/studio/stickers/project-status/group-99.png +0 -0
  261. package/apps/studio/stickers/project-status/manifest.json +328 -0
  262. package/apps/studio/test/_helpers.ts +15 -2
  263. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  264. package/apps/studio/test/acp-bridge.test.ts +34 -1
  265. package/apps/studio/test/acp-commands.test.ts +2 -1
  266. package/apps/studio/test/acp-env.test.ts +30 -0
  267. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  268. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  269. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  270. package/apps/studio/test/annotations-layer.test.ts +174 -0
  271. package/apps/studio/test/asset-api.test.ts +3 -3
  272. package/apps/studio/test/canvas-edit.test.ts +17 -6
  273. package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
  274. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  275. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  276. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  277. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  278. package/apps/studio/test/edit-source-command.test.ts +23 -0
  279. package/apps/studio/test/element-structural-edit.test.ts +154 -0
  280. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  281. package/apps/studio/test/footage-store.test.ts +100 -0
  282. package/apps/studio/test/generate-route.test.ts +106 -0
  283. package/apps/studio/test/git-branches.test.ts +97 -0
  284. package/apps/studio/test/github-api.test.ts +56 -1
  285. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  286. package/apps/studio/test/import-asset-route.test.ts +71 -0
  287. package/apps/studio/test/import-asset.test.ts +399 -0
  288. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  289. package/apps/studio/test/import-brand-route.test.ts +120 -0
  290. package/apps/studio/test/import-brand.test.ts +206 -0
  291. package/apps/studio/test/import-tokens.test.ts +722 -0
  292. package/apps/studio/test/input-router.test.ts +33 -0
  293. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  294. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  295. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  296. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  297. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  298. package/apps/studio/test/photo-filters.test.ts +149 -0
  299. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  300. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  301. package/apps/studio/test/read-annotations.test.ts +164 -0
  302. package/apps/studio/test/readiness.test.ts +13 -11
  303. package/apps/studio/test/scaffold-design.test.ts +122 -0
  304. package/apps/studio/test/shell-importmap.test.ts +49 -0
  305. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  306. package/apps/studio/test/timeline-parse.test.ts +57 -0
  307. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  308. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  309. package/apps/studio/test/undo-stack.test.ts +33 -0
  310. package/apps/studio/test/video-asset.test.ts +5 -5
  311. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  312. package/apps/studio/text-caret.ts +239 -0
  313. package/apps/studio/tool-palette.tsx +49 -21
  314. package/apps/studio/ui-prefs.ts +130 -0
  315. package/apps/studio/undo-stack.ts +94 -2
  316. package/apps/studio/use-annotation-resize.tsx +4 -4
  317. package/apps/studio/use-canvas-media-drop.tsx +40 -1
  318. package/apps/studio/use-chrome-visibility.tsx +8 -2
  319. package/apps/studio/use-selection-set.tsx +21 -0
  320. package/apps/studio/video-comp.tsx +48 -7
  321. package/apps/studio/whats-new.json +149 -0
  322. package/apps/studio/ws.ts +5 -0
  323. package/cli/bin/maude.mjs +6 -6
  324. package/cli/commands/design.mjs +67 -3
  325. package/cli/lib/gitignore-block.mjs +2 -0
  326. package/cli/lib/pkg-root.mjs +107 -0
  327. package/cli/lib/pkg-root.test.mjs +79 -0
  328. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  329. package/cli/lib/update-check.mjs +8 -0
  330. package/package.json +13 -11
  331. package/plugins/design/dependencies.json +18 -0
  332. package/plugins/design/templates/_shell.html +1 -0
@@ -0,0 +1,162 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { applyTextEdit } from '../canvas-edit.ts';
3
+ import { transpileCanvasSource } from '../canvas-pipeline.ts';
4
+
5
+ // unified-text-editing follow-up — editing text that comes from a `{variable}`
6
+ // or `{item.prop}` by tracing it back to its source string (a local const or a
7
+ // `.map()`ed array element). The safety contract: never rewrite the wrong item
8
+ // — occurrence picks the `.map()` slot, `before` verifies/rescues it, and an
9
+ // unresolvable expression still throws (routes to /design:edit).
10
+
11
+ const CANVAS = '/virtual/Cards.tsx';
12
+ const SRC = `const TOPICS = [
13
+ { key: "a", body: "Alpha body text." },
14
+ { key: "b", body: "Beta body text." },
15
+ { key: "c", body: "Gamma body text." },
16
+ ];
17
+ const TAGS = ["first", "second", "third"];
18
+ const HEADLINE = "The one headline";
19
+ export default function Cards() {
20
+ return (
21
+ <div>
22
+ <h1>{HEADLINE}</h1>
23
+ <section>
24
+ {TOPICS.map((t) => (
25
+ <article key={t.key}>
26
+ <p>{t.body}</p>
27
+ </article>
28
+ ))}
29
+ </section>
30
+ <ul>
31
+ {TAGS.map((tag) => (
32
+ <li key={tag}>{tag}</li>
33
+ ))}
34
+ </ul>
35
+ <p>Total: {TOPICS.length} topics</p>
36
+ <span>{price.toFixed(2)}</span>
37
+ </div>
38
+ );
39
+ }
40
+ const price = 9;
41
+ `;
42
+
43
+ function ids(withIds: string, tag: string, inner: string): string {
44
+ // Grab the data-cd-id of the <tag …>inner… element.
45
+ const re = new RegExp(`<${tag}\\s+data-cd-id="([^"]+)"[^>]*>\\s*\\{?${inner}`);
46
+ const m = withIds.match(re);
47
+ if (!m) throw new Error(`no ${tag} matching ${inner}`);
48
+ return m[1] as string;
49
+ }
50
+
51
+ describe('dynamic text edit — resolveDynamicTextSpan via applyTextEdit', () => {
52
+ const { withIds } = transpileCanvasSource(CANVAS, SRC);
53
+
54
+ test('{item.prop} in a .map — occurrence picks the array item', () => {
55
+ const pId = ids(withIds, 'p', 't\\.body');
56
+ // occurrence 1 → TOPICS[1].body, verified by `before`.
57
+ const { source } = applyTextEdit(CANVAS, withIds, pId, 'Beta EDITED', {
58
+ occurrence: 1,
59
+ before: 'Beta body text.',
60
+ });
61
+ expect(source).toContain('body: "Beta EDITED"');
62
+ expect(source).toContain('body: "Alpha body text."'); // sibling untouched
63
+ expect(source).toContain('body: "Gamma body text."');
64
+ });
65
+
66
+ test('{item.prop} — index drift rescued by unique before-match', () => {
67
+ const pId = ids(withIds, 'p', 't\\.body');
68
+ // Wrong occurrence, but `before` uniquely identifies Gamma → rewrite it.
69
+ const { source } = applyTextEdit(CANVAS, withIds, pId, 'Gamma EDITED', {
70
+ occurrence: 0,
71
+ before: 'Gamma body text.',
72
+ });
73
+ expect(source).toContain('body: "Gamma EDITED"');
74
+ expect(source).toContain('body: "Alpha body text."');
75
+ });
76
+
77
+ test('{item} in a .map over a string array', () => {
78
+ const liId = ids(withIds, 'li', 'tag');
79
+ const { source } = applyTextEdit(CANVAS, withIds, liId, 'SECOND', {
80
+ occurrence: 1,
81
+ before: 'second',
82
+ });
83
+ expect(source).toContain('"first", "SECOND", "third"');
84
+ });
85
+
86
+ test('{localConst} — rewrites the const string', () => {
87
+ const h1Id = ids(withIds, 'h1', 'HEADLINE');
88
+ const { source } = applyTextEdit(CANVAS, withIds, h1Id, 'A new headline', {
89
+ before: 'The one headline',
90
+ });
91
+ expect(source).toContain('HEADLINE = "A new headline"');
92
+ });
93
+
94
+ test('{prop.field} — component fed BEATS[k] resolves to BEATS[k].field', () => {
95
+ // A reusable component whose `beat` prop is fed `BEATS[0..2]` at each usage
96
+ // (the Remotion-style pattern). Editing the 2nd usage's caption must hit
97
+ // BEATS[1].caption via occurrence, not card 0 or 2.
98
+ const SRC2 = `const BEATS = [
99
+ { n: "01", caption: "Beat one caption." },
100
+ { n: "02", caption: "Beat two caption." },
101
+ { n: "03", caption: "Beat three caption." },
102
+ ];
103
+ function GuideBeat({ beat }: { beat: (typeof BEATS)[number] }) {
104
+ return <p>{beat.caption}</p>;
105
+ }
106
+ export default function Reel() {
107
+ return (
108
+ <div>
109
+ <GuideBeat beat={BEATS[0]} />
110
+ <GuideBeat beat={BEATS[1]} />
111
+ <GuideBeat beat={BEATS[2]} />
112
+ </div>
113
+ );
114
+ }`;
115
+ const built = transpileCanvasSource(CANVAS, SRC2).withIds;
116
+ const pId = /<p\s+data-cd-id="([^"]+)"[^>]*>\s*\{beat\.caption\}/.exec(built)?.[1] as string;
117
+ const { source } = applyTextEdit(CANVAS, built, pId, 'Beat two EDITED', {
118
+ occurrence: 1,
119
+ before: 'Beat two caption.',
120
+ });
121
+ expect(source).toContain('caption: "Beat two EDITED"');
122
+ expect(source).toContain('caption: "Beat one caption."');
123
+ expect(source).toContain('caption: "Beat three caption."');
124
+ });
125
+
126
+ test('genuinely computed text still refuses (routes to /design:edit)', () => {
127
+ const spanId = ids(withIds, 'span', 'price\\.toFixed');
128
+ expect(() =>
129
+ applyTextEdit(CANVAS, withIds, spanId, 'nope', { occurrence: 0, before: '9.00' })
130
+ ).toThrow(/dynamic content/);
131
+ });
132
+
133
+ test('mixed literal+expression (Total: {n} topics) refuses', () => {
134
+ // This <p> has text + expression + text → mixed, never offered.
135
+ const m = withIds.match(/<p[^>]*data-cd-id="([^"]+)"[^>]*>Total:/);
136
+ expect(m).toBeTruthy();
137
+ expect(() => applyTextEdit(CANVAS, withIds, (m as RegExpMatchArray)[1] as string, 'x')).toThrow(
138
+ /mixed or expression/
139
+ );
140
+ });
141
+
142
+ test('no occurrence + no before on an ambiguous .map refuses (never guesses)', () => {
143
+ const pId = ids(withIds, 'p', 't\\.body');
144
+ // Without occurrence or before there is no way to know which card → refuse.
145
+ expect(() => applyTextEdit(CANVAS, withIds, pId, 'x')).toThrow(/dynamic content/);
146
+ });
147
+ });
148
+
149
+ describe('stamp marks var-editable text', () => {
150
+ const { withIds } = transpileCanvasSource(CANVAS, SRC);
151
+ test('{item.prop}, {item}, {const} get data-cd-editable="var"', () => {
152
+ expect(withIds).toMatch(/<p[^>]*data-cd-editable="var"[^>]*>\s*\{t\.body\}/);
153
+ expect(withIds).toMatch(/<li[^>]*data-cd-editable="var"[^>]*>\s*\{tag\}/);
154
+ expect(withIds).toMatch(/<h1[^>]*data-cd-editable="var"[^>]*>\s*\{HEADLINE\}/);
155
+ });
156
+ test('computed + mixed text are NOT marked', () => {
157
+ const span = withIds.match(/<span[^>]*>\{price\.toFixed/);
158
+ expect((span as RegExpMatchArray)[0].includes('data-cd-editable')).toBe(false);
159
+ const p = withIds.match(/<p[^>]*>Total:/);
160
+ expect((p as RegExpMatchArray)[0].includes('data-cd-editable')).toBe(false);
161
+ });
162
+ });
@@ -30,9 +30,32 @@ describe('createEditSourceCommand', () => {
30
30
  id: 'cd-1',
31
31
  key: 'color',
32
32
  value: 'blue',
33
+ // do() writes `after` over `before`, so `from` = before; css has no occurrence.
34
+ from: 'red',
35
+ occurrence: undefined,
33
36
  });
34
37
  });
35
38
 
39
+ test('do()/undo() carry occurrence + from for a text edit (variable re-target)', async () => {
40
+ const applyFn = mock(() => {});
41
+ const textEdit: EditSourcePayload = {
42
+ op: 'text',
43
+ canvas: '.design/ui/Foo.tsx',
44
+ id: 'cd-9',
45
+ key: '',
46
+ before: 'Old',
47
+ after: 'New',
48
+ occurrence: 2,
49
+ };
50
+ const cmd = createEditSourceCommand({ payload: textEdit, applyFn });
51
+ await cmd.do();
52
+ // redo/do: write New over Old → from = Old.
53
+ expect(applyFn.mock.calls[0]?.[0]).toMatchObject({ value: 'New', from: 'Old', occurrence: 2 });
54
+ await cmd.undo();
55
+ // undo: write Old over New → from = New.
56
+ expect(applyFn.mock.calls[1]?.[0]).toMatchObject({ value: 'Old', from: 'New', occurrence: 2 });
57
+ });
58
+
36
59
  test('undo() applies the BEFORE value', async () => {
37
60
  const applyFn = mock(() => {});
38
61
  const cmd = createEditSourceCommand({ payload: cssEdit, applyFn });
@@ -10,7 +10,10 @@ import {
10
10
  applyDeleteElement,
11
11
  applyInsertArtboard,
12
12
  applyInsertElement,
13
+ applyInsertElementIntoArtboard,
13
14
  applyResizeArtboard,
15
+ applySetArtboardHug,
16
+ applySetArtboardStyle,
14
17
  CanvasEditError,
15
18
  } from '../canvas-edit.ts';
16
19
  import { transpileCanvasSource } from '../canvas-pipeline.ts';
@@ -153,6 +156,51 @@ describe('canvas-edit / applyInsertElement', () => {
153
156
  });
154
157
  });
155
158
 
159
+ describe('canvas-edit / applyInsertElementIntoArtboard (empty-artboard fallback)', () => {
160
+ const emptyCanvas = [
161
+ 'export default function Demo() {',
162
+ ' return (',
163
+ ' <DesignCanvas>',
164
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
165
+ ' </DCArtboard>',
166
+ ' </DesignCanvas>',
167
+ ' );',
168
+ '}',
169
+ ].join('\n');
170
+
171
+ test("inserts a div as the artboard's only child (inside-end)", () => {
172
+ const out = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'div');
173
+ expect(out.source).toMatch(
174
+ /<DCArtboard id="home"[^>]*>[\s\S]*var\(--bg-2\)[\s\S]*<\/DCArtboard>/
175
+ );
176
+ expect(parses(out.source)).toBe(true);
177
+ expect(out.newId).toMatch(/^[0-9a-f]{8}$/);
178
+ });
179
+
180
+ test('inside-start lands the new element right after the opening tag', () => {
181
+ const out = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-start', 'text');
182
+ expect(out.source).toContain('<p style={{ margin: 0 }}>Text</p>');
183
+ expect(parses(out.source)).toBe(true);
184
+ });
185
+
186
+ test('image insert requires a contained asset src', () => {
187
+ expect(() =>
188
+ applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'image')
189
+ ).toThrow(CanvasEditError);
190
+ const ok = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'image', {
191
+ src: 'assets/ab12cd34.png',
192
+ });
193
+ expect(ok.source).toContain('src="assets/ab12cd34.png"');
194
+ expect(parses(ok.source)).toBe(true);
195
+ });
196
+
197
+ test('unknown artboard id throws', () => {
198
+ expect(() =>
199
+ applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'nope', 'inside-end', 'div')
200
+ ).toThrow(CanvasEditError);
201
+ });
202
+ });
203
+
156
204
  describe('canvas-edit / applyResizeArtboard (D4)', () => {
157
205
  const canvas = [
158
206
  'export default function Demo() {',
@@ -185,6 +233,112 @@ describe('canvas-edit / applyResizeArtboard (D4)', () => {
185
233
  });
186
234
  });
187
235
 
236
+ describe('canvas-edit / applySetArtboardHug (Hug ⇄ Fixed toggle)', () => {
237
+ const canvas = [
238
+ 'export default function Demo() {',
239
+ ' return (',
240
+ ' <DesignCanvas>',
241
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
242
+ ' <div>content</div>',
243
+ ' </DCArtboard>',
244
+ ' </DesignCanvas>',
245
+ ' );',
246
+ '}',
247
+ ].join('\n');
248
+
249
+ test('fixed=true adds the bare boolean prop, no string/expr value', () => {
250
+ const out = applySetArtboardHug(CANVAS, canvas, 'home', true);
251
+ expect(out.source).toMatch(/<DCArtboard\b[^>]*\bfixed\b(?!=)[^>]*>/);
252
+ expect(out.source).not.toContain('fixed={true}');
253
+ expect(out.source).not.toContain('fixed="true"');
254
+ expect(parses(out.source)).toBe(true);
255
+ });
256
+
257
+ test('fixed=true with freezeHeight also pins height (numeric prop)', () => {
258
+ const out = applySetArtboardHug(CANVAS, canvas, 'home', true, 777);
259
+ expect(out.source).toContain('fixed');
260
+ expect(out.source).toContain('height={777}');
261
+ expect(parses(out.source)).toBe(true);
262
+ });
263
+
264
+ test('fixed=true is idempotent — no duplicate attr on a board already pinned', () => {
265
+ const once = applySetArtboardHug(CANVAS, canvas, 'home', true);
266
+ const twice = applySetArtboardHug(CANVAS, once.source, 'home', true);
267
+ expect(twice.source.match(/\bfixed\b/g)?.length).toBe(1);
268
+ });
269
+
270
+ test('fixed=false removes the prop, height untouched', () => {
271
+ const pinned = applySetArtboardHug(CANVAS, canvas, 'home', true, 777);
272
+ const out = applySetArtboardHug(CANVAS, pinned.source, 'home', false);
273
+ expect(out.source).not.toContain('fixed');
274
+ expect(out.source).toContain('height={777}'); // stays as the hug floor
275
+ expect(parses(out.source)).toBe(true);
276
+ });
277
+
278
+ test('fixed=false on an already-hug board is a no-op', () => {
279
+ const out = applySetArtboardHug(CANVAS, canvas, 'home', false);
280
+ expect(out.source).toBe(canvas);
281
+ });
282
+
283
+ test('unknown artboard id throws', () => {
284
+ expect(() => applySetArtboardHug(CANVAS, canvas, 'nope', true)).toThrow(CanvasEditError);
285
+ });
286
+ });
287
+
288
+ describe('canvas-edit / applySetArtboardStyle (background/padding/layout/gap)', () => {
289
+ const canvas = [
290
+ 'export default function Demo() {',
291
+ ' return (',
292
+ ' <DesignCanvas>',
293
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
294
+ ' <div>content</div>',
295
+ ' </DCArtboard>',
296
+ ' </DesignCanvas>',
297
+ ' );',
298
+ '}',
299
+ ].join('\n');
300
+
301
+ test('writes background/layout as string literals, padding/gap as numeric', () => {
302
+ const out = applySetArtboardStyle(CANVAS, canvas, 'home', {
303
+ background: 'var(--bg-1)',
304
+ layout: 'flex-col',
305
+ padding: 16,
306
+ gap: 8,
307
+ });
308
+ expect(out.source).toContain('background="var(--bg-1)"');
309
+ expect(out.source).toContain('layout="flex-col"');
310
+ expect(out.source).toContain('padding={16}');
311
+ expect(out.source).toContain('gap={8}');
312
+ expect(parses(out.source)).toBe(true);
313
+ });
314
+
315
+ test('padding/gap of 0 is written verbatim, not clamped to 1', () => {
316
+ const out = applySetArtboardStyle(CANVAS, canvas, 'home', { padding: 0, gap: 0 });
317
+ expect(out.source).toContain('padding={0}');
318
+ expect(out.source).toContain('gap={0}');
319
+ });
320
+
321
+ test('null resets a previously-set prop (removes the attribute)', () => {
322
+ const styled = applySetArtboardStyle(CANVAS, canvas, 'home', { background: 'red' });
323
+ const out = applySetArtboardStyle(CANVAS, styled.source, 'home', { background: null });
324
+ expect(out.source).not.toContain('background=');
325
+ expect(parses(out.source)).toBe(true);
326
+ });
327
+
328
+ test('an absent key leaves that prop untouched', () => {
329
+ const styled = applySetArtboardStyle(CANVAS, canvas, 'home', { background: 'red', padding: 4 });
330
+ const out = applySetArtboardStyle(CANVAS, styled.source, 'home', { padding: 8 });
331
+ expect(out.source).toContain('background="red"'); // untouched
332
+ expect(out.source).toContain('padding={8}');
333
+ });
334
+
335
+ test('unknown artboard id throws', () => {
336
+ expect(() => applySetArtboardStyle(CANVAS, canvas, 'nope', { padding: 4 })).toThrow(
337
+ CanvasEditError
338
+ );
339
+ });
340
+ });
341
+
188
342
  describe('canvas-edit / applyInsertArtboard (I4)', () => {
189
343
  const canvas = [
190
344
  'export default function Demo() {',
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Test fixture standing in for the `claude` CLI — implements only the one
3
+ // subcommand probeAcpAvailabilityAuthed()/getClaudeAuthStatus() shell out to.
4
+ // FAKE_CLAUDE_LOGGED_IN=1 controls the reported sign-in state.
5
+ const [, , cmd, sub] = process.argv;
6
+ if (cmd === 'auth' && sub === 'status') {
7
+ const loggedIn = process.env.FAKE_CLAUDE_LOGGED_IN === '1';
8
+ process.stdout.write(
9
+ JSON.stringify({ loggedIn, apiProvider: loggedIn ? 'firstParty' : undefined })
10
+ );
11
+ process.exit(0);
12
+ }
13
+ process.exit(1);
@@ -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
+ });
@@ -0,0 +1,106 @@
1
+ // generate-route.test.ts — the privileged /_api/generate* routes on the MAIN
2
+ // origin. Boots a real server (no CANVAS_ORIGIN_SPLIT; loopback host). Proves:
3
+ // validation rejects bad bodies; a key POST reports {configured} and GET never
4
+ // echoes the value; the provider catalogue serializes with a presence flag; and
5
+ // a generate POST enqueues a job (202 {jobId}) that fails cleanly with no key
6
+ // configured (no real Google call). Points MAUDE_GEN_KEYS_PATH at the sandbox
7
+ // so the user's real key store is never touched.
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
15
+
16
+ let keysDir: string;
17
+ beforeEach(() => {
18
+ keysDir = mkdtempSync(join(tmpdir(), 'maude-genroute-'));
19
+ });
20
+ afterEach(() => rmSync(keysDir, { recursive: true, force: true }));
21
+
22
+ describe('/_api/generate* main-origin routes', () => {
23
+ test('providers catalogue, key custody (no echo), validation, and enqueue', async () => {
24
+ const { root } = makeSandbox();
25
+ const port = nextPort();
26
+ const proc = await bootServer(root, port, {
27
+ MAUDE_GEN_KEYS_PATH: join(keysDir, 'keys.json'),
28
+ });
29
+ const base = `http://localhost:${port}`;
30
+ try {
31
+ // Provider catalogue — gemini present, not yet configured.
32
+ const provRes = await fetch(`${base}/_api/generate/providers`);
33
+ expect(provRes.status).toBe(200);
34
+ const provJson = (await provRes.json()) as {
35
+ providers: Array<{ id: string; configured: boolean }>;
36
+ };
37
+ const gemini = provJson.providers.find((p) => p.id === 'gemini');
38
+ expect(gemini).toBeTruthy();
39
+ expect(gemini?.configured).toBe(false);
40
+
41
+ // Set a key → {configured:true}; the value is NEVER echoed back.
42
+ const setRes = await fetch(`${base}/_api/generate/keys`, {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'text/plain' },
45
+ body: JSON.stringify({ provider: 'gemini', key: 'AIza-super-secret' }),
46
+ });
47
+ expect(setRes.status).toBe(200);
48
+ const setJson = (await setRes.json()) as Record<string, unknown>;
49
+ expect(setJson.configured).toBe(true);
50
+ expect(JSON.stringify(setJson)).not.toContain('super-secret');
51
+
52
+ // GET key status → presence only, no value.
53
+ const statusRes = await fetch(`${base}/_api/generate/keys`);
54
+ const statusJson = (await statusRes.json()) as { configured: string[] };
55
+ expect(statusJson.configured).toContain('gemini');
56
+ expect(JSON.stringify(statusJson)).not.toContain('super-secret');
57
+
58
+ // Validation — a malformed generate body 400s before any provider call.
59
+ const badRes = await fetch(`${base}/_api/generate-jobs`, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'text/plain' },
62
+ body: JSON.stringify({ modality: 'hologram', provider: '../etc' }),
63
+ });
64
+ expect(badRes.status).toBe(400);
65
+
66
+ // Unknown provider 400s.
67
+ const unkRes = await fetch(`${base}/_api/generate-jobs`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'text/plain' },
70
+ body: JSON.stringify({ modality: 'image', provider: 'nope', prompt: 'x' }),
71
+ });
72
+ expect(unkRes.status).toBe(400);
73
+
74
+ // A well-formed request enqueues → 202 { jobId }. (The run will hit the
75
+ // real Gemini endpoint with a bogus key and fail in the background — we
76
+ // only assert the queue accepted it and surfaces the job.)
77
+ const okRes = await fetch(`${base}/_api/generate-jobs`, {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'text/plain' },
80
+ body: JSON.stringify({ modality: 'image', provider: 'gemini', prompt: 'a red circle' }),
81
+ });
82
+ expect(okRes.status).toBe(202);
83
+ const okJson = (await okRes.json()) as { jobId: string };
84
+ expect(okJson.jobId).toMatch(/^gen_/);
85
+
86
+ // The job shows up in the list.
87
+ const listRes = await fetch(`${base}/_api/generate-jobs`);
88
+ const listJson = (await listRes.json()) as { jobs: Array<{ id: string }> };
89
+ expect(listJson.jobs.some((j) => j.id === okJson.jobId)).toBe(true);
90
+
91
+ // Remove the key.
92
+ const delRes = await fetch(`${base}/_api/generate/keys`, {
93
+ method: 'DELETE',
94
+ headers: { 'content-type': 'text/plain' },
95
+ body: JSON.stringify({ provider: 'gemini' }),
96
+ });
97
+ expect(delRes.status).toBe(200);
98
+ const afterDel = (await (await fetch(`${base}/_api/generate/keys`)).json()) as {
99
+ configured: string[];
100
+ };
101
+ expect(afterDel.configured).not.toContain('gemini');
102
+ } finally {
103
+ await killProc(proc);
104
+ }
105
+ });
106
+ });