undercity 1.0.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 (382) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +58 -0
  3. package/actions/AGENTS.md +41 -0
  4. package/actions/_shared/container.js +16 -0
  5. package/actions/auth/ask-login/action.json +15 -0
  6. package/actions/auth/ask-signup/action.json +14 -0
  7. package/actions/display/clear/action.json +18 -0
  8. package/actions/display/clear/action.test.js +32 -0
  9. package/actions/display/markdown/action.json +12 -0
  10. package/actions/display/markdown/action.test.js +32 -0
  11. package/actions/display/rawHtml/action.json +24 -0
  12. package/actions/display/rawHtml/action.test.js +32 -0
  13. package/actions/display/safeHtml/action.json +24 -0
  14. package/actions/display/safeHtml/action.test.js +32 -0
  15. package/actions/display/text/action.js +9 -0
  16. package/actions/display/text/action.json +12 -0
  17. package/actions/display/text/action.test.js +40 -0
  18. package/actions/display/value/action.json +24 -0
  19. package/actions/display/value/action.test.js +32 -0
  20. package/actions/dom/addClass/action.json +23 -0
  21. package/actions/dom/addClass/action.test.js +32 -0
  22. package/actions/dom/focus/action.json +17 -0
  23. package/actions/dom/focus/action.test.js +32 -0
  24. package/actions/dom/hide/action.json +18 -0
  25. package/actions/dom/hide/action.test.js +32 -0
  26. package/actions/dom/removeClass/action.json +22 -0
  27. package/actions/dom/removeClass/action.test.js +32 -0
  28. package/actions/dom/scroll/action.json +29 -0
  29. package/actions/dom/scroll/action.test.js +32 -0
  30. package/actions/dom/setAttr/action.json +29 -0
  31. package/actions/dom/setAttr/action.test.js +32 -0
  32. package/actions/dom/setHtml/action.json +22 -0
  33. package/actions/dom/setHtml/action.test.js +32 -0
  34. package/actions/dom/setStyle/action.json +29 -0
  35. package/actions/dom/setStyle/action.test.js +32 -0
  36. package/actions/dom/setText/action.json +24 -0
  37. package/actions/dom/setText/action.test.js +32 -0
  38. package/actions/dom/show/action.json +18 -0
  39. package/actions/dom/show/action.test.js +32 -0
  40. package/actions/dom/toggle/action.json +17 -0
  41. package/actions/dom/toggle/action.test.js +32 -0
  42. package/actions/dom/toggleClass/action.json +22 -0
  43. package/actions/dom/toggleClass/action.test.js +32 -0
  44. package/actions/event/emit/action.json +24 -0
  45. package/actions/event/emit/action.test.js +32 -0
  46. package/actions/event/on/action.json +23 -0
  47. package/actions/event/on/action.test.js +32 -0
  48. package/actions/event/waitFor/action.json +28 -0
  49. package/actions/event/waitFor/action.test.js +32 -0
  50. package/actions/forms/bindField/action.js +27 -0
  51. package/actions/forms/bindField/action.json +24 -0
  52. package/actions/forms/bindField/action.test.js +20 -0
  53. package/actions/forms/check/action.json +22 -0
  54. package/actions/forms/check/action.test.js +32 -0
  55. package/actions/forms/clearErrors/action.json +11 -0
  56. package/actions/forms/clearErrors/action.test.js +35 -0
  57. package/actions/forms/clearField/action.json +17 -0
  58. package/actions/forms/clearField/action.test.js +32 -0
  59. package/actions/forms/getField/action.json +24 -0
  60. package/actions/forms/getField/action.test.js +32 -0
  61. package/actions/forms/getRange/action.json +22 -0
  62. package/actions/forms/getRange/action.test.js +32 -0
  63. package/actions/forms/getSelect/action.json +22 -0
  64. package/actions/forms/getSelect/action.test.js +32 -0
  65. package/actions/forms/index.js +140 -0
  66. package/actions/forms/serialize/action.json +24 -0
  67. package/actions/forms/serialize/action.test.js +32 -0
  68. package/actions/forms/setCheck/action.json +23 -0
  69. package/actions/forms/setCheck/action.test.js +32 -0
  70. package/actions/forms/setError/action.json +22 -0
  71. package/actions/forms/setError/action.test.js +32 -0
  72. package/actions/forms/setField/action.js +14 -0
  73. package/actions/forms/setField/action.json +24 -0
  74. package/actions/forms/setField/action.test.js +32 -0
  75. package/actions/forms/submit/action.json +18 -0
  76. package/actions/forms/submit/action.test.js +32 -0
  77. package/actions/forms/validate/action.json +24 -0
  78. package/actions/forms/validate/action.test.js +32 -0
  79. package/actions/http/delete/action.json +22 -0
  80. package/actions/http/delete/action.test.js +32 -0
  81. package/actions/http/get/action.json +24 -0
  82. package/actions/http/get/action.test.js +32 -0
  83. package/actions/http/post/action.json +30 -0
  84. package/actions/http/post/action.test.js +32 -0
  85. package/actions/http/put/action.json +27 -0
  86. package/actions/http/put/action.test.js +32 -0
  87. package/actions/http/upload/action.json +35 -0
  88. package/actions/http/upload/action.test.js +32 -0
  89. package/actions/index.js +306 -0
  90. package/actions/index.json +5 -0
  91. package/actions/input/askChoice/action.json +29 -0
  92. package/actions/input/askChoice/action.test.js +32 -0
  93. package/actions/input/askConfirm/action.json +24 -0
  94. package/actions/input/askConfirm/action.test.js +32 -0
  95. package/actions/input/askDate/action.json +24 -0
  96. package/actions/input/askDate/action.test.js +32 -0
  97. package/actions/input/askEmail/action.json +24 -0
  98. package/actions/input/askEmail/action.test.js +32 -0
  99. package/actions/input/askNumber/action.json +35 -0
  100. package/actions/input/askNumber/action.test.js +32 -0
  101. package/actions/input/askPassword/action.json +24 -0
  102. package/actions/input/askPassword/action.test.js +32 -0
  103. package/actions/input/askText/action.json +36 -0
  104. package/actions/input/askText/action.test.js +32 -0
  105. package/actions/logic/delay/action.json +18 -0
  106. package/actions/logic/delay/action.test.js +32 -0
  107. package/actions/logic/if/action.json +28 -0
  108. package/actions/logic/if/action.test.js +32 -0
  109. package/actions/logic/log/action.json +18 -0
  110. package/actions/logic/log/action.test.js +32 -0
  111. package/actions/logic/random/action.json +36 -0
  112. package/actions/logic/random/action.test.js +32 -0
  113. package/actions/logic/transform/action.json +24 -0
  114. package/actions/logic/transform/action.test.js +32 -0
  115. package/actions/media/askAudioUpload/action.json +30 -0
  116. package/actions/media/askAudioUpload/action.test.js +32 -0
  117. package/actions/media/askFileUpload/action.json +36 -0
  118. package/actions/media/askFileUpload/action.test.js +32 -0
  119. package/actions/media/askImageUpload/action.json +37 -0
  120. package/actions/media/askImageUpload/action.test.js +32 -0
  121. package/actions/media/askVideoUpload/action.js +74 -0
  122. package/actions/media/askVideoUpload/action.json +44 -0
  123. package/actions/media/askVideoUpload/action.test.js +51 -0
  124. package/actions/media/captureWebcam/action.json +24 -0
  125. package/actions/media/captureWebcam/action.test.js +32 -0
  126. package/actions/nav/back/action.json +11 -0
  127. package/actions/nav/back/action.test.js +35 -0
  128. package/actions/nav/goto/action.json +18 -0
  129. package/actions/nav/goto/action.test.js +32 -0
  130. package/actions/nav/redirect/action.json +28 -0
  131. package/actions/nav/redirect/action.test.js +32 -0
  132. package/actions/nav/reload/action.json +11 -0
  133. package/actions/nav/reload/action.test.js +35 -0
  134. package/actions/nav/reset/action.json +11 -0
  135. package/actions/nav/reset/action.test.js +35 -0
  136. package/actions/render/alert/action.js +12 -0
  137. package/actions/render/alert/action.json +36 -0
  138. package/actions/render/alert/action.test.js +32 -0
  139. package/actions/render/button/action.js +15 -0
  140. package/actions/render/button/action.json +44 -0
  141. package/actions/render/button/action.test.js +37 -0
  142. package/actions/render/clear/action.js +7 -0
  143. package/actions/render/clear/action.json +11 -0
  144. package/actions/render/clear/action.test.js +35 -0
  145. package/actions/render/divider/action.js +8 -0
  146. package/actions/render/divider/action.json +11 -0
  147. package/actions/render/divider/action.test.js +35 -0
  148. package/actions/render/field/action.js +17 -0
  149. package/actions/render/field/action.json +59 -0
  150. package/actions/render/field/action.test.js +57 -0
  151. package/actions/render/link/action.js +20 -0
  152. package/actions/render/link/action.json +30 -0
  153. package/actions/render/link/action.test.js +32 -0
  154. package/actions/render/markdown/action.json +18 -0
  155. package/actions/render/markdown/action.test.js +32 -0
  156. package/actions/render/paragraph/action.js +9 -0
  157. package/actions/render/paragraph/action.json +32 -0
  158. package/actions/render/paragraph/action.test.js +32 -0
  159. package/actions/render/section/action.js +10 -0
  160. package/actions/render/section/action.json +18 -0
  161. package/actions/render/section/action.test.js +32 -0
  162. package/actions/render/subtitle/action.js +9 -0
  163. package/actions/render/subtitle/action.json +18 -0
  164. package/actions/render/subtitle/action.test.js +32 -0
  165. package/actions/render/title/action.js +9 -0
  166. package/actions/render/title/action.json +30 -0
  167. package/actions/render/title/action.test.js +44 -0
  168. package/actions/session/clear/action.json +11 -0
  169. package/actions/session/clear/action.test.js +35 -0
  170. package/actions/session/load/action.json +22 -0
  171. package/actions/session/load/action.test.js +32 -0
  172. package/actions/session/local/action.json +22 -0
  173. package/actions/session/local/action.test.js +32 -0
  174. package/actions/session/save/action.json +22 -0
  175. package/actions/session/save/action.test.js +32 -0
  176. package/actions/ui/accordion/action.json +23 -0
  177. package/actions/ui/accordion/action.test.js +32 -0
  178. package/actions/ui/badge/action.json +22 -0
  179. package/actions/ui/badge/action.test.js +32 -0
  180. package/actions/ui/collapse/action.json +23 -0
  181. package/actions/ui/collapse/action.test.js +32 -0
  182. package/actions/ui/hideModal/action.json +17 -0
  183. package/actions/ui/hideModal/action.test.js +32 -0
  184. package/actions/ui/loading/action.json +18 -0
  185. package/actions/ui/loading/action.test.js +32 -0
  186. package/actions/ui/modal/action.json +18 -0
  187. package/actions/ui/modal/action.test.js +32 -0
  188. package/actions/ui/progress/action.json +24 -0
  189. package/actions/ui/progress/action.test.js +32 -0
  190. package/actions/ui/toast/action.json +29 -0
  191. package/actions/ui/toast/action.test.js +32 -0
  192. package/actions/ui/tooltip/action.json +17 -0
  193. package/actions/ui/tooltip/action.test.js +32 -0
  194. package/actions/user/carry/action.json +25 -0
  195. package/actions/user/carry/action.test.js +32 -0
  196. package/actions/user/check/action.json +24 -0
  197. package/actions/user/check/action.test.js +32 -0
  198. package/actions/user/clear/action.json +11 -0
  199. package/actions/user/clear/action.test.js +35 -0
  200. package/actions/user/delete/action.json +17 -0
  201. package/actions/user/delete/action.test.js +32 -0
  202. package/actions/user/dump/action.json +11 -0
  203. package/actions/user/dump/action.test.js +35 -0
  204. package/actions/user/get/action.json +24 -0
  205. package/actions/user/get/action.test.js +32 -0
  206. package/actions/user/merge/action.json +18 -0
  207. package/actions/user/merge/action.test.js +32 -0
  208. package/actions/user/set/action.json +24 -0
  209. package/actions/user/set/action.test.js +32 -0
  210. package/generator/base/css/bootstrap.min.css +6 -0
  211. package/generator/base/icons/app-indicator.svg +4 -0
  212. package/generator/base/icons/backpack.svg +4 -0
  213. package/generator/base/icons/broadcast.svg +3 -0
  214. package/generator/base/icons/bullseye.svg +6 -0
  215. package/generator/base/icons/chat-dots.svg +4 -0
  216. package/generator/base/icons/check-circle.svg +4 -0
  217. package/generator/base/icons/clipboard-check.svg +5 -0
  218. package/generator/base/icons/clipboard.svg +4 -0
  219. package/generator/base/icons/copy.svg +3 -0
  220. package/generator/base/icons/cursor.svg +3 -0
  221. package/generator/base/icons/diamond.svg +3 -0
  222. package/generator/base/icons/exclamation-triangle.svg +4 -0
  223. package/generator/base/icons/film.svg +3 -0
  224. package/generator/base/icons/floppy.svg +4 -0
  225. package/generator/base/icons/gear-wide-connected.svg +3 -0
  226. package/generator/base/icons/gear.svg +4 -0
  227. package/generator/base/icons/globe.svg +3 -0
  228. package/generator/base/icons/image.svg +4 -0
  229. package/generator/base/icons/layout-text-window.svg +4 -0
  230. package/generator/base/icons/lightning-charge.svg +3 -0
  231. package/generator/base/icons/magic.svg +3 -0
  232. package/generator/base/icons/pencil-square.svg +4 -0
  233. package/generator/base/icons/record-circle.svg +4 -0
  234. package/generator/base/icons/robot.svg +4 -0
  235. package/generator/base/icons/shield-check.svg +4 -0
  236. package/generator/base/icons/shield-lock.svg +4 -0
  237. package/generator/base/icons/signpost.svg +3 -0
  238. package/generator/base/icons/stars.svg +3 -0
  239. package/generator/base/icons/type.svg +3 -0
  240. package/generator/base/js/bootstrap.bundle.min.js +7 -0
  241. package/package.json +14 -0
  242. package/packages/undercity-http-server/index.js +249 -0
  243. package/packages/undercity-http-server/package.json +10 -0
  244. package/packages/undercity-parser/index.js +323 -0
  245. package/packages/undercity-parser/lexer.js +128 -0
  246. package/packages/undercity-parser/package.json +11 -0
  247. package/plugins/forms.js +397 -0
  248. package/plugins/index.js +83 -0
  249. package/plugins/multipage.js +165 -0
  250. package/plugins/wizard.js +239 -0
  251. package/projects/asd/project.json +1031 -0
  252. package/projects/test-1/project.json +335 -0
  253. package/projects/test-a/project.json +456 -0
  254. package/public/icons/arrows-angle-expand.svg +3 -0
  255. package/public/icons/arrows-fullscreen.svg +3 -0
  256. package/public/icons/bezier2.svg +3 -0
  257. package/public/icons/bootstrap/app-indicator.svg +4 -0
  258. package/public/icons/bootstrap/arrow-clockwise.svg +4 -0
  259. package/public/icons/bootstrap/arrow-counterclockwise.svg +4 -0
  260. package/public/icons/bootstrap/arrow-left.svg +3 -0
  261. package/public/icons/bootstrap/arrows-angle-expand.svg +3 -0
  262. package/public/icons/bootstrap/arrows-fullscreen.svg +3 -0
  263. package/public/icons/bootstrap/backpack.svg +4 -0
  264. package/public/icons/bootstrap/bezier2.svg +3 -0
  265. package/public/icons/bootstrap/bookmark-check.svg +4 -0
  266. package/public/icons/bootstrap/bookmark-plus.svg +4 -0
  267. package/public/icons/bootstrap/box-arrow-right.svg +4 -0
  268. package/public/icons/bootstrap/box-arrow-up.svg +4 -0
  269. package/public/icons/bootstrap/broadcast.svg +3 -0
  270. package/public/icons/bootstrap/bullseye.svg +6 -0
  271. package/public/icons/bootstrap/chat-dots.svg +4 -0
  272. package/public/icons/bootstrap/check-circle.svg +4 -0
  273. package/public/icons/bootstrap/check2.svg +3 -0
  274. package/public/icons/bootstrap/clipboard-check.svg +5 -0
  275. package/public/icons/bootstrap/clipboard.svg +4 -0
  276. package/public/icons/bootstrap/clock-history.svg +5 -0
  277. package/public/icons/bootstrap/command.svg +3 -0
  278. package/public/icons/bootstrap/copy.svg +3 -0
  279. package/public/icons/bootstrap/cursor.svg +3 -0
  280. package/public/icons/bootstrap/diagram-3.svg +3 -0
  281. package/public/icons/bootstrap/diamond.svg +3 -0
  282. package/public/icons/bootstrap/exclamation-triangle.svg +4 -0
  283. package/public/icons/bootstrap/eye.svg +4 -0
  284. package/public/icons/bootstrap/file-earmark-plus.svg +4 -0
  285. package/public/icons/bootstrap/film.svg +3 -0
  286. package/public/icons/bootstrap/floppy.svg +4 -0
  287. package/public/icons/bootstrap/folder2-open.svg +3 -0
  288. package/public/icons/bootstrap/gear-wide-connected.svg +3 -0
  289. package/public/icons/bootstrap/gear.svg +4 -0
  290. package/public/icons/bootstrap/globe.svg +3 -0
  291. package/public/icons/bootstrap/grid-3x3-gap.svg +3 -0
  292. package/public/icons/bootstrap/house-door.svg +3 -0
  293. package/public/icons/bootstrap/image.svg +4 -0
  294. package/public/icons/bootstrap/layout-text-window.svg +4 -0
  295. package/public/icons/bootstrap/lightning-charge.svg +3 -0
  296. package/public/icons/bootstrap/magic.svg +3 -0
  297. package/public/icons/bootstrap/pencil-square.svg +4 -0
  298. package/public/icons/bootstrap/pencil.svg +3 -0
  299. package/public/icons/bootstrap/play.svg +3 -0
  300. package/public/icons/bootstrap/plus-circle.svg +4 -0
  301. package/public/icons/bootstrap/plus-lg.svg +3 -0
  302. package/public/icons/bootstrap/record-circle.svg +4 -0
  303. package/public/icons/bootstrap/robot.svg +4 -0
  304. package/public/icons/bootstrap/save.svg +3 -0
  305. package/public/icons/bootstrap/scissors.svg +3 -0
  306. package/public/icons/bootstrap/shield-check.svg +4 -0
  307. package/public/icons/bootstrap/shield-lock.svg +4 -0
  308. package/public/icons/bootstrap/signpost.svg +3 -0
  309. package/public/icons/bootstrap/stars.svg +3 -0
  310. package/public/icons/bootstrap/stop-circle.svg +4 -0
  311. package/public/icons/bootstrap/terminal.svg +4 -0
  312. package/public/icons/bootstrap/trash3.svg +3 -0
  313. package/public/icons/bootstrap/type.svg +3 -0
  314. package/public/icons/bootstrap/x-circle.svg +4 -0
  315. package/public/icons/bootstrap/x-lg.svg +3 -0
  316. package/public/icons/bootstrap/zoom-in.svg +5 -0
  317. package/public/icons/bootstrap/zoom-out.svg +5 -0
  318. package/public/icons/bullseye.svg +6 -0
  319. package/public/icons/check2.svg +3 -0
  320. package/public/icons/cursor.svg +3 -0
  321. package/public/icons/diamond.svg +3 -0
  322. package/public/icons/eye.svg +4 -0
  323. package/public/icons/file-earmark-plus.svg +4 -0
  324. package/public/icons/floppy.svg +4 -0
  325. package/public/icons/gear.svg +4 -0
  326. package/public/icons/lightning-charge.svg +3 -0
  327. package/public/icons/pencil.svg +3 -0
  328. package/public/icons/play.svg +3 -0
  329. package/public/icons/plus-circle.svg +4 -0
  330. package/public/icons/record-circle.svg +4 -0
  331. package/public/icons/robot.svg +4 -0
  332. package/public/icons/save.svg +3 -0
  333. package/public/icons/stop-circle.svg +4 -0
  334. package/public/icons/terminal.svg +4 -0
  335. package/public/icons/trash3.svg +3 -0
  336. package/public/icons/x-circle.svg +4 -0
  337. package/public/icons/zoom-in.svg +5 -0
  338. package/public/icons/zoom-out.svg +5 -0
  339. package/public/index.html +424 -0
  340. package/public/testbench.html +899 -0
  341. package/scripts/extract-actions.js +128 -0
  342. package/server.js +11 -0
  343. package/src/emitter.js +48 -0
  344. package/src/generator/css.js +135 -0
  345. package/src/generator/index.js +122 -0
  346. package/src/generator/md-renderer-src.js +77 -0
  347. package/src/generator/page.js +300 -0
  348. package/src/generator/runtime.js +1632 -0
  349. package/src/generator/templates.js +508 -0
  350. package/src/ide/action-library.js +856 -0
  351. package/src/ide/af-icons.js +127 -0
  352. package/src/ide/app.js +1375 -0
  353. package/src/ide/command-line/commands.js +242 -0
  354. package/src/ide/command-line/index.js +329 -0
  355. package/src/ide/command-line/parser.js +21 -0
  356. package/src/ide/css/ide.css +1501 -0
  357. package/src/ide/graph.js +282 -0
  358. package/src/ide/history.js +46 -0
  359. package/src/ide/map-builder.js +583 -0
  360. package/src/ide/project-api.js +39 -0
  361. package/src/ide/savant-chat.js +513 -0
  362. package/src/ide/savant.js +1287 -0
  363. package/src/ide/thing-library.js +89 -0
  364. package/src/ide/undercity-map.js +978 -0
  365. package/src/lib/icons.js +72 -0
  366. package/src/lib/scope.js +88 -0
  367. package/src/lib/signal.js +155 -0
  368. package/src/lib/state-machine.js +113 -0
  369. package/src/server/index.js +96 -0
  370. package/src/server/routes/actions.js +144 -0
  371. package/src/server/routes/ai.js +176 -0
  372. package/src/server/routes/generate.js +54 -0
  373. package/src/server/routes/projects.js +106 -0
  374. package/src/server/routes/reset.js +30 -0
  375. package/src/server/routes/submit.js +30 -0
  376. package/src/server/routes/templates.js +139 -0
  377. package/src/server/routes/things.js +33 -0
  378. package/templates/auth-flow.json +335 -0
  379. package/templates/blank.json +39 -0
  380. package/things/auth-server/thing.json +17 -0
  381. package/things/persona-live/thing.json +20 -0
  382. package/things/workflow/thing.json +15 -0
@@ -0,0 +1,899 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Undercity Action Testbench</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "undercity/shared": "/actions/_shared/container.js"
11
+ }
12
+ }
13
+ </script>
14
+ <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
15
+ <style>
16
+ :root {
17
+ --sol-base03:#002b36;--sol-base02:#073642;--sol-base01:#586e75;
18
+ --sol-base0:#839496;--sol-base1:#93a1a1;--sol-base3:#fdf6e3;
19
+ --sol-yellow:#b58900;--sol-orange:#cb4b16;--sol-red:#dc322f;
20
+ --sol-magenta:#d33682;--sol-violet:#6c71c4;--sol-blue:#268bd2;
21
+ --sol-cyan:#2aa198;--sol-green:#859900;
22
+ --bg:var(--sol-base03);--bg2:var(--sol-base02);
23
+ --border:#073642;--text:var(--sol-base0);--text-em:var(--sol-base1);
24
+ }
25
+ *{box-sizing:border-box;margin:0;padding:0;}
26
+ body{background:var(--bg);color:var(--text);font-family:'SF Mono',Consolas,monospace;font-size:13px;display:flex;height:100vh;overflow:hidden;}
27
+
28
+ /* Sidebar */
29
+ #sidebar{width:220px;min-width:220px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
30
+ #sidebar-title{padding:14px 16px;font-weight:700;font-size:14px;color:var(--sol-cyan);border-bottom:1px solid var(--border);letter-spacing:.04em;}
31
+ #suite-list{flex:1;overflow-y:auto;padding:8px 0;}
32
+ .suite-item{padding:7px 16px;cursor:pointer;color:var(--text);border-left:3px solid transparent;transition:all .15s;}
33
+ .suite-item:hover{background:var(--bg);color:var(--text-em);}
34
+ .suite-item.active{border-left-color:var(--sol-cyan);color:var(--sol-cyan);background:var(--bg);}
35
+ .suite-badge{float:right;font-size:10px;padding:1px 6px;border-radius:10px;background:var(--sol-base01);color:#fff;}
36
+ .suite-badge.pass{background:var(--sol-green);}
37
+ .suite-badge.fail{background:var(--sol-red);}
38
+ .suite-badge.running{background:var(--sol-yellow);}
39
+
40
+ /* Main */
41
+ #main{flex:1;display:flex;flex-direction:column;overflow:hidden;}
42
+ #toolbar{padding:10px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
43
+ #toolbar h1{font-size:14px;font-weight:700;color:var(--text-em);flex:1;}
44
+ .btn-tb{background:var(--bg2);border:1px solid var(--border);color:var(--text-em);padding:5px 14px;border-radius:4px;cursor:pointer;font-size:12px;}
45
+ .btn-tb:hover{border-color:var(--sol-cyan);color:var(--sol-cyan);}
46
+ #status-bar{padding:6px 16px;font-size:11px;color:var(--sol-base01);border-bottom:1px solid var(--border);}
47
+ #content{flex:1;display:flex;overflow:hidden;}
48
+
49
+ /* Test list panel */
50
+ #test-panel{width:320px;min-width:320px;border-right:1px solid var(--border);overflow-y:auto;padding:8px 0;}
51
+ .test-row{padding:7px 16px;display:flex;align-items:center;gap:8px;cursor:pointer;border-left:3px solid transparent;}
52
+ .test-row:hover{background:var(--bg2);}
53
+ .test-row.active{border-left-color:var(--sol-blue);background:var(--bg2);}
54
+ .test-row.pass .test-dot{color:var(--sol-green);}
55
+ .test-row.fail .test-dot{color:var(--sol-red);}
56
+ .test-row.skip .test-dot{color:var(--sol-base01);}
57
+ .test-row.running .test-dot{color:var(--sol-yellow);}
58
+ .test-dot{font-size:16px;line-height:1;}
59
+ .test-name{flex:1;font-size:12px;}
60
+ .test-time{font-size:10px;color:var(--sol-base01);}
61
+
62
+ /* Detail panel */
63
+ #detail-panel{flex:1;overflow-y:auto;padding:16px;}
64
+ #detail-panel h2{font-size:13px;font-weight:700;color:var(--text-em);margin-bottom:10px;}
65
+ .detail-section{margin-bottom:14px;}
66
+ .detail-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--sol-base01);margin-bottom:5px;}
67
+ .code-block{background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:10px 12px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--sol-base1);}
68
+ .code-block.error{border-color:var(--sol-red);color:var(--sol-red);}
69
+ .code-block.pass{border-color:var(--sol-green);color:var(--sol-green);}
70
+
71
+ /* DOM sandbox — hidden off-screen */
72
+ #sandbox{position:fixed;left:-9999px;top:0;width:600px;pointer-events:none;}
73
+ /* Give the sandbox Bootstrap context */
74
+ #sandbox #pw-content{width:100%;min-height:10px;}
75
+
76
+ /* Progress */
77
+ #progress-bar{height:3px;background:var(--sol-cyan);width:0;transition:width .2s;}
78
+ </style>
79
+ </head>
80
+ <body>
81
+
82
+ <aside id="sidebar">
83
+ <div id="sidebar-title">⚗ Testbench</div>
84
+ <div id="suite-list"></div>
85
+ </aside>
86
+
87
+ <main id="main">
88
+ <div id="toolbar">
89
+ <h1 id="suite-title">Undercity Action Testbench</h1>
90
+ <button class="btn-tb" id="btn-run-all">▶ Run All</button>
91
+ <button class="btn-tb" id="btn-run-suite">▶ Suite</button>
92
+ </div>
93
+ <div id="progress-bar"></div>
94
+ <div id="status-bar" id="status">Ready — click "Run All" to begin.</div>
95
+ <div id="content">
96
+ <div id="test-panel"></div>
97
+ <div id="detail-panel"><p style="color:var(--sol-base01)">Select a test to inspect.</p></div>
98
+ </div>
99
+ </main>
100
+
101
+ <!-- Off-screen DOM sandbox for action testing -->
102
+ <div id="sandbox">
103
+ <div id="pw-content"></div>
104
+ <div id="pw-form"></div>
105
+ <div id="pw-loading" class="d-none"></div>
106
+ <div id="pw-input-modal" style="display:none">
107
+ <span id="pw-input-label"></span>
108
+ <input id="pw-input-field" class="form-control">
109
+ <button id="pw-input-confirm">OK</button>
110
+ </div>
111
+ </div>
112
+
113
+ <script type="module">
114
+ // ── Testbench engine ─────────────────────────────────────────────────────────
115
+
116
+ const PROJ_ID = '__testbench__';
117
+ const BASE_URL = ''; // same origin
118
+ let RT = null; // loaded runtime module
119
+ let allResults = {}; // suite → [{name, status, err, domBefore, domAfter, ms}]
120
+ let activeSuite = null;
121
+ let activeTest = null;
122
+
123
+ // ── Bootstrap the runtime ────────────────────────────────────────────────────
124
+
125
+ async function bootRuntime() {
126
+ setStatus('Generating test runtime…');
127
+
128
+ const projPayload = {
129
+ id: PROJ_ID, name: 'Testbench',
130
+ graph: { nodes: [{ id: 'lobby', type: 'room', label: 'Lobby', template: 'lobby', x:100, y:100, meta:{ isEntry:true, appName:'Test', tagline:'' }, payload:{ onEnter:[], onExit:[], onBack:[], onReset:[], onUnload:[] } }], edges: [] },
131
+ inventory: { schema: {} },
132
+ customActions: {}
133
+ };
134
+
135
+ // Upsert project
136
+ await fetch(`${BASE_URL}/api/projects/${PROJ_ID}`, {
137
+ method: 'PUT',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify(projPayload)
140
+ }).catch(() => {});
141
+
142
+ // Create if PUT fails (try POST)
143
+ try {
144
+ const check = await fetch(`${BASE_URL}/api/projects/${PROJ_ID}`);
145
+ if (!check.ok) {
146
+ await fetch(`${BASE_URL}/api/projects`, {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify(projPayload)
150
+ });
151
+ }
152
+ } catch {}
153
+
154
+ // Generate
155
+ const genRes = await fetch(`${BASE_URL}/api/projects/${PROJ_ID}/generate`, { method: 'POST' });
156
+ if (!genRes.ok) throw new Error(`Generate failed: ${genRes.status}`);
157
+ const genData = await genRes.json().catch(() => ({}));
158
+ const rtUrl = `${BASE_URL}/generated/${PROJ_ID}/js/runtime.js`;
159
+
160
+ // Dynamic import with cache-bust
161
+ RT = await import(rtUrl + '?t=' + Date.now());
162
+ setStatus('Runtime loaded. Ready.');
163
+ return RT;
164
+ }
165
+
166
+ // ── Test utilities ───────────────────────────────────────────────────────────
167
+
168
+ function clearSandbox() {
169
+ document.getElementById('pw-content').innerHTML = '';
170
+ document.getElementById('pw-form').innerHTML = '';
171
+ }
172
+
173
+ function sandboxHTML() {
174
+ return document.getElementById('pw-content').innerHTML ||
175
+ document.getElementById('pw-form').innerHTML || '(empty)';
176
+ }
177
+
178
+ function sandboxText() {
179
+ const el = document.getElementById('pw-content');
180
+ return el.textContent.trim() || '(empty)';
181
+ }
182
+
183
+ function expect(label, actual, expected) {
184
+ if (actual !== expected) throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
185
+ }
186
+
187
+ function expectContains(label, haystack, needle) {
188
+ if (!String(haystack).includes(needle))
189
+ throw new Error(`${label}: expected to contain ${JSON.stringify(needle)}, got: ${String(haystack).slice(0,200)}`);
190
+ }
191
+
192
+ function expectMatch(label, str, pattern) {
193
+ if (!pattern.test(str))
194
+ throw new Error(`${label}: ${JSON.stringify(str.slice(0,200))} did not match ${pattern}`);
195
+ }
196
+
197
+ function expectElement(selector) {
198
+ const el = document.getElementById('pw-content').querySelector(selector) ||
199
+ document.getElementById('pw-form').querySelector(selector);
200
+ if (!el) throw new Error(`Expected element matching "${selector}" in sandbox, but not found`);
201
+ return el;
202
+ }
203
+
204
+ function expectNoElement(selector) {
205
+ const el = document.getElementById('pw-content').querySelector(selector);
206
+ if (el) throw new Error(`Expected NO element matching "${selector}", but found one`);
207
+ }
208
+
209
+ // ── Test suite definitions ───────────────────────────────────────────────────
210
+
211
+ const SUITES = {
212
+
213
+ 'render.*': [
214
+ {
215
+ name: 'render.clear empties #pw-content',
216
+ run() {
217
+ document.getElementById('pw-content').innerHTML = '<p>old</p>';
218
+ RT.Render.clear();
219
+ expect('innerHTML', document.getElementById('pw-content').innerHTML, '');
220
+ }
221
+ },
222
+ {
223
+ name: 'render.title appends heading',
224
+ run() {
225
+ RT.Render.title('Hello World');
226
+ const h = expectElement('h2');
227
+ expectContains('textContent', h.textContent, 'Hello World');
228
+ }
229
+ },
230
+ {
231
+ name: 'render.title uses specified size',
232
+ run() {
233
+ RT.Render.title('Big', 'h1');
234
+ expectElement('h1');
235
+ }
236
+ },
237
+ {
238
+ name: 'render.subtitle appends muted paragraph',
239
+ run() {
240
+ RT.Render.subtitle('Sub text');
241
+ const p = expectElement('p');
242
+ expectContains('textContent', p.textContent, 'Sub text');
243
+ expectContains('class', p.className, 'text-muted');
244
+ }
245
+ },
246
+ {
247
+ name: 'render.paragraph appends paragraph',
248
+ run() {
249
+ RT.Render.paragraph('Some content');
250
+ const p = expectElement('p');
251
+ expectContains('textContent', p.textContent, 'Some content');
252
+ }
253
+ },
254
+ {
255
+ name: 'render.field appends label + input',
256
+ run() {
257
+ RT.Render.field('email', 'Email Address', 'email', 'you@example.com');
258
+ const label = expectElement('label');
259
+ const input = expectElement('input[type="email"]');
260
+ expectContains('label', label.textContent, 'Email Address');
261
+ expect('input name', input.name, 'email');
262
+ expectContains('placeholder', input.placeholder, 'you@example.com');
263
+ }
264
+ },
265
+ {
266
+ name: 'render.field required flag sets aria-required',
267
+ run() {
268
+ RT.Render.field('pw', 'Password', 'password', '', '', true);
269
+ const input = expectElement('input[type="password"]');
270
+ expect('required attr', input.required, true);
271
+ }
272
+ },
273
+ {
274
+ name: 'render.alert appends hidden alert div',
275
+ run() {
276
+ RT.Render.alert('my-alert', 'danger', '');
277
+ const div = expectElement('#my-alert');
278
+ expectContains('class', div.className, 'd-none');
279
+ expectContains('class', div.className, 'alert-danger');
280
+ }
281
+ },
282
+ {
283
+ name: 'render.alert with text shows immediately',
284
+ run() {
285
+ RT.Render.alert('err-msg', 'warning', 'Something went wrong');
286
+ const div = expectElement('#err-msg');
287
+ expect('d-none absent', div.classList.contains('d-none'), false);
288
+ expectContains('text', div.textContent, 'Something went wrong');
289
+ }
290
+ },
291
+ {
292
+ name: 'render.button appends button element',
293
+ run() {
294
+ RT.Render.button('Sign In', 'auth-check', 'primary', true);
295
+ const btn = expectElement('button');
296
+ expectContains('label', btn.textContent, 'Sign In');
297
+ expectContains('class', btn.className, 'btn-primary');
298
+ expectContains('class', btn.className, 'w-100');
299
+ }
300
+ },
301
+ {
302
+ name: 'render.button non-full-width has no w-100',
303
+ run() {
304
+ RT.Render.button('Back', 'lobby', 'outline-secondary', false);
305
+ const btn = expectElement('button');
306
+ expect('no w-100', btn.classList.contains('w-100'), false);
307
+ }
308
+ },
309
+ {
310
+ name: 'render.link appends anchor',
311
+ run() {
312
+ RT.Render.link('Forgot password?', 'forgot', '');
313
+ const a = expectElement('a');
314
+ expectContains('text', a.textContent, 'Forgot password?');
315
+ expectContains('class', a.className, 'text-info');
316
+ }
317
+ },
318
+ {
319
+ name: 'render.link with prefix shows prefix text',
320
+ run() {
321
+ RT.Render.link('Sign up', 'signup', "Don't have an account?");
322
+ const p = expectElement('p');
323
+ expectContains('text', p.textContent, "Don't have an account?");
324
+ expectContains('text', p.textContent, 'Sign up');
325
+ }
326
+ },
327
+ {
328
+ name: 'render.section appends uppercase label',
329
+ run() {
330
+ RT.Render.section('Personal Details');
331
+ const el = document.getElementById('pw-content').querySelector('div.text-uppercase');
332
+ if (!el) throw new Error('Expected uppercase section div');
333
+ expectContains('text', el.textContent, 'Personal Details');
334
+ }
335
+ },
336
+ {
337
+ name: 'render.divider appends <hr>',
338
+ run() {
339
+ RT.Render.divider();
340
+ expectElement('hr');
341
+ }
342
+ },
343
+ {
344
+ name: 'render.markdown renders bold text',
345
+ run() {
346
+ RT.Render.markdown('**Hello** world');
347
+ const div = expectElement('div.af-md');
348
+ expectMatch('contains bold', div.innerHTML, /<strong>/);
349
+ }
350
+ },
351
+ {
352
+ name: 'render.markdown renders heading',
353
+ run() {
354
+ RT.Render.markdown('# Big Title');
355
+ const div = expectElement('div.af-md');
356
+ expectElement('h1') || expectElement('h2');
357
+ }
358
+ },
359
+ {
360
+ name: 'render.* via runPayload (integration)',
361
+ async run() {
362
+ await RT.runPayload([
363
+ { action: 'render.clear', params: {} },
364
+ { action: 'render.title', params: { text: 'Via Payload' } },
365
+ { action: 'render.subtitle', params: { text: 'Sub' } },
366
+ { action: 'render.field', params: { name: 'email', label: 'Email', type: 'email', placeholder: 'test@example.com', autocomplete: 'email', required: true } },
367
+ { action: 'render.button', params: { label: 'Next', target: 'next-step', variant: 'primary', full: true } },
368
+ ]);
369
+ expectElement('h2');
370
+ expectElement('input[type="email"]');
371
+ expectElement('button');
372
+ }
373
+ },
374
+ ],
375
+
376
+ 'display.*': [
377
+ {
378
+ name: 'display.text sets textContent',
379
+ run() {
380
+ document.getElementById('pw-content').innerHTML = '<p id="out">old</p>';
381
+ RT.Display.text('#out', 'new value');
382
+ expect('text', document.getElementById('out').textContent, 'new value');
383
+ }
384
+ },
385
+ {
386
+ name: 'display.text escapes HTML',
387
+ run() {
388
+ document.getElementById('pw-content').innerHTML = '<p id="out"></p>';
389
+ RT.Display.text('#out', '<script>alert(1)</script>');
390
+ expect('text', document.getElementById('out').textContent, '<script>alert(1)</script>');
391
+ }
392
+ },
393
+ {
394
+ name: 'display.safeHtml strips script tags',
395
+ run() {
396
+ document.getElementById('pw-content').innerHTML = '<div id="out"></div>';
397
+ RT.Display.safeHtml('#out', '<p>Hi</p><script>evil()</script>');
398
+ expect('no script', document.getElementById('out').innerHTML.includes('<script>'), false);
399
+ expectContains('has p', document.getElementById('out').innerHTML, '<p>');
400
+ }
401
+ },
402
+ {
403
+ name: 'display.rawHtml injects HTML verbatim',
404
+ run() {
405
+ document.getElementById('pw-content').innerHTML = '<div id="out"></div>';
406
+ RT.Display.rawHtml('#out', '<b>bold</b>');
407
+ expectContains('has b', document.getElementById('out').innerHTML, '<b>');
408
+ }
409
+ },
410
+ {
411
+ name: 'display.clear empties target',
412
+ run() {
413
+ document.getElementById('pw-content').innerHTML = '<div id="out"><p>stuff</p></div>';
414
+ RT.Display.clear('#out');
415
+ expect('empty', document.getElementById('out').innerHTML, '');
416
+ }
417
+ },
418
+ {
419
+ name: 'display.markdown renders inline code',
420
+ run() {
421
+ document.getElementById('pw-content').innerHTML = '<div id="out"></div>';
422
+ RT.Display.markdown('#out', 'Use `npm install`');
423
+ expectContains('code', document.getElementById('out').innerHTML, '<code>');
424
+ }
425
+ },
426
+ {
427
+ name: 'display.value prints inventory key',
428
+ run() {
429
+ RT.Inventory.set('userName', 'Alice');
430
+ document.getElementById('pw-content').innerHTML = '<p id="out"></p>';
431
+ RT.Display.value('#out', 'userName');
432
+ expect('text', document.getElementById('out').textContent, 'Alice');
433
+ }
434
+ },
435
+ ],
436
+
437
+ 'Inventory': [
438
+ {
439
+ name: 'set and get a value',
440
+ run() {
441
+ RT.Inventory.set('x', 42);
442
+ expect('get', RT.Inventory.get('x'), 42);
443
+ }
444
+ },
445
+ {
446
+ name: 'delete removes a key',
447
+ run() {
448
+ RT.Inventory.set('tmp', 'hello');
449
+ RT.Inventory.delete('tmp');
450
+ expect('deleted', RT.Inventory.get('tmp'), null);
451
+ }
452
+ },
453
+ {
454
+ name: 'merge updates multiple keys',
455
+ run() {
456
+ RT.Inventory.set('a', 1);
457
+ RT.Inventory.merge({ a: 2, b: 3 });
458
+ expect('a', RT.Inventory.get('a'), 2);
459
+ expect('b', RT.Inventory.get('b'), 3);
460
+ }
461
+ },
462
+ {
463
+ name: 'dump returns all keys',
464
+ run() {
465
+ RT.Inventory.clear();
466
+ RT.Inventory.set('k1', 'v1');
467
+ RT.Inventory.set('k2', 'v2');
468
+ const d = RT.Inventory.dump();
469
+ expect('k1', d.k1, 'v1');
470
+ expect('k2', d.k2, 'v2');
471
+ }
472
+ },
473
+ {
474
+ name: 'clear removes everything',
475
+ run() {
476
+ RT.Inventory.set('foo', 'bar');
477
+ RT.Inventory.clear();
478
+ const d = RT.Inventory.dump();
479
+ expect('empty', Object.keys(d).length, 0);
480
+ }
481
+ },
482
+ ],
483
+
484
+ 'User': [
485
+ {
486
+ name: 'carry stores a rich item',
487
+ run() {
488
+ RT.User.carry('sword', { damage: 10 });
489
+ expect('has sword', RT.User.has('sword'), true);
490
+ const item = RT.User.inspect('sword');
491
+ expect('damage', item.damage, 10);
492
+ expect('_isItem', item._isItem, true);
493
+ }
494
+ },
495
+ {
496
+ name: 'drop removes item',
497
+ run() {
498
+ RT.User.carry('shield', { armor: 5 });
499
+ RT.User.drop('shield');
500
+ expect('has shield', RT.User.has('shield'), false);
501
+ }
502
+ },
503
+ {
504
+ name: 'has returns false for missing item',
505
+ run() {
506
+ expect('missing', RT.User.has('nonexistent'), false);
507
+ }
508
+ },
509
+ {
510
+ name: 'User delegates get/set to Inventory',
511
+ run() {
512
+ RT.User.set('score', 100);
513
+ expect('get', RT.User.get('score'), 100);
514
+ }
515
+ },
516
+ ],
517
+
518
+ 'runPayload': [
519
+ {
520
+ name: 'runs empty payload without error',
521
+ async run() {
522
+ await RT.runPayload([]);
523
+ }
524
+ },
525
+ {
526
+ name: 'unknown action logs warning, does not throw',
527
+ async run() {
528
+ await RT.runPayload([{ action: 'fake.noSuchThing', params: { x: 1 } }]);
529
+ }
530
+ },
531
+ {
532
+ name: 'stores return value via into',
533
+ async run() {
534
+ RT.Inventory.clear();
535
+ // ui.toast returns undefined; use a function that returns a value
536
+ // Inventory.get returns a value we can store back
537
+ RT.Inventory.set('src', 'hello');
538
+ await RT.runPayload([{ action: 'Inventory.get', params: { key: 'src', into: 'dst' } }]);
539
+ expect('dst', RT.Inventory.get('dst'), 'hello');
540
+ }
541
+ },
542
+ {
543
+ name: 'inventory.set via payload',
544
+ async run() {
545
+ await RT.runPayload([{ action: 'inventory.set', params: { key: 'color', value: 'blue' } }]);
546
+ expect('color', RT.Inventory.get('color'), 'blue');
547
+ }
548
+ },
549
+ {
550
+ name: 'user.carry via payload',
551
+ async run() {
552
+ await RT.runPayload([{ action: 'user.carry', params: { itemId: 'gem', itemData: { value: 5 } } }]);
553
+ expect('has gem', RT.User.has('gem'), true);
554
+ }
555
+ },
556
+ ],
557
+
558
+ 'Navigator': [
559
+ {
560
+ name: 'Navigator.back exists and is a function',
561
+ run() {
562
+ expect('type', typeof RT.Navigator.back, 'function');
563
+ }
564
+ },
565
+ {
566
+ name: 'Navigator.reload exists and is a function',
567
+ run() {
568
+ expect('type', typeof RT.Navigator.reload, 'function');
569
+ }
570
+ },
571
+ {
572
+ name: 'Navigator.goto is a function',
573
+ run() {
574
+ expect('type', typeof RT.Navigator.goto, 'function');
575
+ }
576
+ },
577
+ ],
578
+
579
+ 'Markdown (unit)': [
580
+ {
581
+ name: 'renders **bold** correctly',
582
+ run() {
583
+ RT.Render.markdown('**bold text**');
584
+ expectMatch('strong', document.getElementById('pw-content').innerHTML, /<strong>/);
585
+ }
586
+ },
587
+ {
588
+ name: 'renders _italic_ correctly',
589
+ run() {
590
+ RT.Render.markdown('_italic text_');
591
+ expectMatch('em', document.getElementById('pw-content').innerHTML, /<em>/);
592
+ }
593
+ },
594
+ {
595
+ name: 'renders ~~strikethrough~~',
596
+ run() {
597
+ RT.Render.markdown('~~struck~~');
598
+ expectMatch('del', document.getElementById('pw-content').innerHTML, /<del>/);
599
+ }
600
+ },
601
+ {
602
+ name: 'renders unordered list',
603
+ run() {
604
+ RT.Render.markdown('- item one\n- item two');
605
+ expectElement('ul');
606
+ const items = document.getElementById('pw-content').querySelectorAll('li');
607
+ expect('li count', items.length, 2);
608
+ }
609
+ },
610
+ {
611
+ name: 'renders ordered list',
612
+ run() {
613
+ RT.Render.markdown('1. first\n2. second\n3. third');
614
+ expectElement('ol');
615
+ const items = document.getElementById('pw-content').querySelectorAll('li');
616
+ expect('li count', items.length, 3);
617
+ }
618
+ },
619
+ {
620
+ name: 'renders blockquote',
621
+ run() {
622
+ RT.Render.markdown('> A wise quote');
623
+ expectElement('blockquote');
624
+ }
625
+ },
626
+ {
627
+ name: 'renders horizontal rule ---',
628
+ run() {
629
+ RT.Render.markdown('---');
630
+ expectElement('hr');
631
+ }
632
+ },
633
+ {
634
+ name: 'renders fenced code block',
635
+ run() {
636
+ RT.Render.markdown('```js\nconst x = 1;\n```');
637
+ expectElement('pre');
638
+ expectElement('code');
639
+ }
640
+ },
641
+ {
642
+ name: 'renders setext h1',
643
+ run() {
644
+ RT.Render.markdown('Title\n=====');
645
+ expectElement('h1');
646
+ }
647
+ },
648
+ {
649
+ name: 'escapes HTML entities in text',
650
+ run() {
651
+ RT.Render.markdown('a < b & c > d');
652
+ expectMatch('escaped', document.getElementById('pw-content').innerHTML, /&lt;/);
653
+ }
654
+ },
655
+ ],
656
+
657
+ };
658
+
659
+ // ── Results state ────────────────────────────────────────────────────────────
660
+
661
+ function makeResult(name) {
662
+ return { name, status: 'skip', err: null, domBefore: '', domAfter: '', ms: 0 };
663
+ }
664
+
665
+ function initResults() {
666
+ allResults = {};
667
+ for (const [suite, tests] of Object.entries(SUITES)) {
668
+ allResults[suite] = tests.map(t => makeResult(t.name));
669
+ }
670
+ }
671
+
672
+ // ── Runner ───────────────────────────────────────────────────────────────────
673
+
674
+ async function runTest(suiteKey, testIdx) {
675
+ const test = SUITES[suiteKey][testIdx];
676
+ const res = allResults[suiteKey][testIdx];
677
+
678
+ clearSandbox();
679
+ res.domBefore = sandboxHTML();
680
+ res.status = 'running';
681
+ renderSuiteItem(suiteKey);
682
+ renderTestRow(suiteKey, testIdx);
683
+
684
+ const sandbox = document.getElementById('sandbox');
685
+ const t0 = performance.now();
686
+ try {
687
+ // Pass RT and sandbox to both inline and external tests
688
+ await test.run(RT, sandbox);
689
+ res.status = 'pass';
690
+ res.err = null;
691
+ } catch (e) {
692
+ res.status = 'fail';
693
+ res.err = e.message || String(e);
694
+ } finally {
695
+ res.ms = Math.round(performance.now() - t0);
696
+ res.domAfter = sandboxHTML();
697
+ renderSuiteItem(suiteKey);
698
+ renderTestRow(suiteKey, testIdx);
699
+ if (activeTest === suiteKey + ':' + testIdx) renderDetail(suiteKey, testIdx);
700
+ }
701
+ }
702
+
703
+ async function runSuite(suiteKey) {
704
+ const tests = SUITES[suiteKey];
705
+ for (let i = 0; i < tests.length; i++) {
706
+ await runTest(suiteKey, i);
707
+ }
708
+ }
709
+
710
+ async function runAll() {
711
+ for (const suite of Object.keys(SUITES)) {
712
+ await runSuite(suite);
713
+ updateProgress();
714
+ }
715
+ updateStatusSummary();
716
+ }
717
+
718
+ // ── Rendering ────────────────────────────────────────────────────────────────
719
+
720
+ const dotFor = { pass:'●', fail:'✕', skip:'○', running:'◌' };
721
+
722
+ function renderSidebar() {
723
+ const list = document.getElementById('suite-list');
724
+ list.innerHTML = Object.keys(SUITES).map((s, i) =>
725
+ `<div class="suite-item${activeSuite === s ? ' active' : ''}" data-suite="${s}">
726
+ ${s}
727
+ <span class="suite-badge" id="badge-${i}"></span>
728
+ </div>`
729
+ ).join('');
730
+ list.querySelectorAll('.suite-item').forEach(el => {
731
+ el.addEventListener('click', () => {
732
+ activeSuite = el.dataset.suite;
733
+ renderSidebar();
734
+ renderTestList(activeSuite);
735
+ });
736
+ });
737
+ }
738
+
739
+ function renderSuiteItem(suiteKey) {
740
+ const idx = Object.keys(SUITES).indexOf(suiteKey);
741
+ const badge = document.getElementById('badge-' + idx);
742
+ if (!badge) return;
743
+ const results = allResults[suiteKey] ?? [];
744
+ const pass = results.filter(r => r.status === 'pass').length;
745
+ const fail = results.filter(r => r.status === 'fail').length;
746
+ const run = results.filter(r => r.status === 'running').length;
747
+ if (run) { badge.textContent = '…'; badge.className = 'suite-badge running'; }
748
+ else if (fail) { badge.textContent = `${pass}/${results.length}`; badge.className = 'suite-badge fail'; }
749
+ else if (pass === results.length) { badge.textContent = pass; badge.className = 'suite-badge pass'; }
750
+ else { badge.textContent = `${pass}/${results.length}`; badge.className = 'suite-badge'; }
751
+ }
752
+
753
+ function renderTestList(suiteKey) {
754
+ document.getElementById('suite-title').textContent = suiteKey;
755
+ const panel = document.getElementById('test-panel');
756
+ const results = allResults[suiteKey] ?? [];
757
+ panel.innerHTML = results.map((r, i) =>
758
+ `<div class="test-row ${r.status}${activeTest === suiteKey+':'+i ? ' active' : ''}" data-idx="${i}">
759
+ <span class="test-dot">${dotFor[r.status] || '○'}</span>
760
+ <span class="test-name">${escHtml(r.name)}</span>
761
+ <span class="test-time">${r.ms ? r.ms + 'ms' : ''}</span>
762
+ </div>`
763
+ ).join('');
764
+ panel.querySelectorAll('.test-row').forEach(el => {
765
+ el.addEventListener('click', () => {
766
+ const i = +el.dataset.idx;
767
+ activeTest = suiteKey + ':' + i;
768
+ renderTestList(suiteKey);
769
+ renderDetail(suiteKey, i);
770
+ });
771
+ });
772
+ }
773
+
774
+ function renderTestRow(suiteKey, idx) {
775
+ if (activeSuite !== suiteKey) return;
776
+ renderTestList(suiteKey);
777
+ }
778
+
779
+ function renderDetail(suiteKey, idx) {
780
+ const r = allResults[suiteKey]?.[idx];
781
+ if (!r) return;
782
+ const panel = document.getElementById('detail-panel');
783
+ const statusColor = r.status === 'pass' ? 'var(--sol-green)' : r.status === 'fail' ? 'var(--sol-red)' : 'var(--sol-base01)';
784
+ panel.innerHTML = `
785
+ <h2>${escHtml(r.name)}</h2>
786
+ <div class="detail-section">
787
+ <div class="detail-label">Status</div>
788
+ <div style="color:${statusColor};font-weight:700;font-size:13px;">${r.status.toUpperCase()}${r.ms ? ' · ' + r.ms + 'ms' : ''}</div>
789
+ </div>
790
+ ${r.err ? `<div class="detail-section">
791
+ <div class="detail-label">Error</div>
792
+ <div class="code-block error">${escHtml(r.err)}</div>
793
+ </div>` : ''}
794
+ <div class="detail-section">
795
+ <div class="detail-label">DOM Before</div>
796
+ <div class="code-block">${escHtml(r.domBefore)}</div>
797
+ </div>
798
+ <div class="detail-section">
799
+ <div class="detail-label">DOM After</div>
800
+ <div class="code-block ${r.status === 'pass' ? 'pass' : ''}">${escHtml(r.domAfter)}</div>
801
+ </div>
802
+ `;
803
+ }
804
+
805
+ function updateProgress() {
806
+ const all = Object.values(allResults).flat();
807
+ const done = all.filter(r => r.status !== 'skip' && r.status !== 'running').length;
808
+ const pct = all.length ? Math.round(done / all.length * 100) : 0;
809
+ document.getElementById('progress-bar').style.width = pct + '%';
810
+ }
811
+
812
+ function updateStatusSummary() {
813
+ const all = Object.values(allResults).flat();
814
+ const pass = all.filter(r => r.status === 'pass').length;
815
+ const fail = all.filter(r => r.status === 'fail').length;
816
+ const total = all.length;
817
+ const color = fail ? 'var(--sol-red)' : 'var(--sol-green)';
818
+ document.getElementById('status-bar').innerHTML =
819
+ `<span style="color:${color};font-weight:700;">${pass} passed</span> · ${fail} failed · ${total} total`;
820
+ }
821
+
822
+ function setStatus(msg) {
823
+ document.getElementById('status-bar').textContent = msg;
824
+ }
825
+
826
+ function escHtml(s) {
827
+ return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
828
+ }
829
+
830
+ // ── Load external action plugin tests ────────────────────────────────────────
831
+
832
+ async function loadActionTests() {
833
+ try {
834
+ const list = await fetch('/api/actions/tests').then(r => r.json());
835
+ const byNs = {}; // namespace → [{name, run}]
836
+
837
+ await Promise.allSettled(list.map(async ({ actionId, url }) => {
838
+ try {
839
+ const mod = await import(url + '?t=' + Date.now());
840
+ const tests = mod.tests ?? [];
841
+ const ns = actionId.split('.')[0];
842
+ if (!byNs[ns]) byNs[ns] = [];
843
+ byNs[ns].push(...tests);
844
+ } catch {}
845
+ }));
846
+
847
+ // Merge into SUITES grouped by namespace
848
+ for (const [ns, tests] of Object.entries(byNs)) {
849
+ const key = ns + '.* (plugins)';
850
+ SUITES[key] = tests;
851
+ }
852
+ } catch {}
853
+ }
854
+
855
+ // ── Init ─────────────────────────────────────────────────────────────────────
856
+
857
+ initResults();
858
+ activeSuite = Object.keys(SUITES)[0];
859
+ renderSidebar();
860
+ renderTestList(activeSuite);
861
+
862
+ document.getElementById('btn-run-all').addEventListener('click', async () => {
863
+ if (!RT) {
864
+ try { await bootRuntime(); }
865
+ catch (e) { setStatus('Boot failed: ' + e.message); return; }
866
+ await loadActionTests();
867
+ }
868
+ initResults();
869
+ renderSidebar();
870
+ renderTestList(activeSuite);
871
+ document.getElementById('progress-bar').style.width = '0';
872
+ await runAll();
873
+ Object.keys(SUITES).forEach(renderSuiteItem);
874
+ });
875
+
876
+ document.getElementById('btn-run-suite').addEventListener('click', async () => {
877
+ if (!RT) {
878
+ try { await bootRuntime(); }
879
+ catch (e) { setStatus('Boot failed: ' + e.message); return; }
880
+ }
881
+ if (!activeSuite) return;
882
+ setStatus(`Running suite: ${activeSuite}…`);
883
+ await runSuite(activeSuite);
884
+ updateStatusSummary();
885
+ Object.keys(SUITES).forEach(renderSuiteItem);
886
+ });
887
+
888
+ // Auto-select first suite in sidebar
889
+ document.getElementById('suite-list').querySelector('.suite-item')?.click();
890
+
891
+ // Try to boot and load action tests silently on load
892
+ bootRuntime().then(() => loadActionTests().then(() => {
893
+ initResults();
894
+ renderSidebar();
895
+ renderTestList(activeSuite);
896
+ })).catch(() => {});
897
+ </script>
898
+ </body>
899
+ </html>