@invisibleloop/pulse 0.1.21

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 (344) hide show
  1. package/.claude/commands/build-page.md +59 -0
  2. package/.claude/commands/new-doc-page.md +45 -0
  3. package/.claude/commands/verify.md +52 -0
  4. package/.claude/pulse-checklist.md +111 -0
  5. package/.claude/settings.local.json +102 -0
  6. package/.github/workflows/ci.yml +22 -0
  7. package/.github/workflows/publish.yml +41 -0
  8. package/.pulse/load-reports/home/1773432711417.json +22 -0
  9. package/CLAUDE.md +383 -0
  10. package/README.md +95 -0
  11. package/docs/.claude/pulse-checklist.md +111 -0
  12. package/docs/public/.pulse-ui-version +1 -0
  13. package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
  14. package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
  15. package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
  16. package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
  17. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
  18. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
  19. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
  20. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
  21. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
  22. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
  23. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
  24. package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
  25. package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
  26. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
  27. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
  28. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
  29. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
  30. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
  31. package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
  32. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
  33. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
  34. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
  35. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
  36. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
  37. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
  38. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
  39. package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
  40. package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
  41. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
  42. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
  43. package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
  44. package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
  45. package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
  46. package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
  47. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
  48. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
  49. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
  50. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
  51. package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
  52. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
  53. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
  54. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
  55. package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
  56. package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
  57. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
  58. package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
  59. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
  60. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
  61. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
  62. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
  63. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
  64. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
  65. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
  66. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
  67. package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
  68. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
  69. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
  70. package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
  71. package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
  72. package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
  73. package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
  74. package/docs/public/dist/docs-8e3d4b5c.css +1 -0
  75. package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
  76. package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
  77. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
  78. package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
  79. package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
  80. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
  81. package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
  82. package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
  83. package/docs/public/dist/manifest.json +94 -0
  84. package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
  85. package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
  86. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
  87. package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
  88. package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
  89. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
  90. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
  91. package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
  92. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
  93. package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
  94. package/docs/public/dist/runtime-B73WLANC.js +1 -0
  95. package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
  96. package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
  97. package/docs/public/dist/runtime-QFURDKA2.js +5 -0
  98. package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
  99. package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
  100. package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
  101. package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
  102. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
  103. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
  104. package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
  105. package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
  106. package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
  107. package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
  108. package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
  109. package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
  110. package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
  111. package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
  112. package/docs/public/docs.css +2020 -0
  113. package/docs/public/menu.js +83 -0
  114. package/docs/public/pulse-ui.css +2739 -0
  115. package/docs/public/pulse-ui.js +236 -0
  116. package/docs/server.js +192 -0
  117. package/docs/src/lib/component-page.js +47 -0
  118. package/docs/src/lib/highlight.js +255 -0
  119. package/docs/src/lib/layout.js +131 -0
  120. package/docs/src/lib/metrics-store.js +6 -0
  121. package/docs/src/lib/nav.js +159 -0
  122. package/docs/src/lib/stats.js +81 -0
  123. package/docs/src/pages/accessibility.js +157 -0
  124. package/docs/src/pages/actions.js +191 -0
  125. package/docs/src/pages/auth.js +177 -0
  126. package/docs/src/pages/caching.js +95 -0
  127. package/docs/src/pages/components/accordion.js +48 -0
  128. package/docs/src/pages/components/alert.js +35 -0
  129. package/docs/src/pages/components/app-badge.js +41 -0
  130. package/docs/src/pages/components/avatar.js +35 -0
  131. package/docs/src/pages/components/badge.js +36 -0
  132. package/docs/src/pages/components/banner.js +45 -0
  133. package/docs/src/pages/components/breadcrumbs.js +94 -0
  134. package/docs/src/pages/components/button.js +84 -0
  135. package/docs/src/pages/components/card.js +225 -0
  136. package/docs/src/pages/components/carousel.js +72 -0
  137. package/docs/src/pages/components/charts.js +278 -0
  138. package/docs/src/pages/components/checkbox.js +129 -0
  139. package/docs/src/pages/components/cluster.js +47 -0
  140. package/docs/src/pages/components/code-window.js +57 -0
  141. package/docs/src/pages/components/container.js +40 -0
  142. package/docs/src/pages/components/cta.js +53 -0
  143. package/docs/src/pages/components/divider.js +37 -0
  144. package/docs/src/pages/components/empty.js +36 -0
  145. package/docs/src/pages/components/feature.js +60 -0
  146. package/docs/src/pages/components/fieldset.js +65 -0
  147. package/docs/src/pages/components/fileupload.js +127 -0
  148. package/docs/src/pages/components/footer.js +58 -0
  149. package/docs/src/pages/components/grid.js +165 -0
  150. package/docs/src/pages/components/heading.js +107 -0
  151. package/docs/src/pages/components/hero.js +65 -0
  152. package/docs/src/pages/components/icons.js +285 -0
  153. package/docs/src/pages/components/image.js +71 -0
  154. package/docs/src/pages/components/input.js +51 -0
  155. package/docs/src/pages/components/list.js +112 -0
  156. package/docs/src/pages/components/media.js +51 -0
  157. package/docs/src/pages/components/modal.js +111 -0
  158. package/docs/src/pages/components/nav.js +86 -0
  159. package/docs/src/pages/components/pricing.js +68 -0
  160. package/docs/src/pages/components/progress.js +102 -0
  161. package/docs/src/pages/components/prose.js +111 -0
  162. package/docs/src/pages/components/pullquote.js +71 -0
  163. package/docs/src/pages/components/radio.js +194 -0
  164. package/docs/src/pages/components/rating.js +106 -0
  165. package/docs/src/pages/components/search.js +61 -0
  166. package/docs/src/pages/components/section.js +59 -0
  167. package/docs/src/pages/components/segmented.js +121 -0
  168. package/docs/src/pages/components/select.js +45 -0
  169. package/docs/src/pages/components/slider.js +114 -0
  170. package/docs/src/pages/components/spinner.js +73 -0
  171. package/docs/src/pages/components/stack.js +48 -0
  172. package/docs/src/pages/components/stat.js +55 -0
  173. package/docs/src/pages/components/stepper.js +66 -0
  174. package/docs/src/pages/components/table.js +45 -0
  175. package/docs/src/pages/components/testimonial.js +49 -0
  176. package/docs/src/pages/components/textarea.js +31 -0
  177. package/docs/src/pages/components/timeline.js +227 -0
  178. package/docs/src/pages/components/toggle.js +84 -0
  179. package/docs/src/pages/components/tooltip.js +48 -0
  180. package/docs/src/pages/components.js +204 -0
  181. package/docs/src/pages/config.js +193 -0
  182. package/docs/src/pages/constraints.js +99 -0
  183. package/docs/src/pages/deploy.js +233 -0
  184. package/docs/src/pages/extending.js +198 -0
  185. package/docs/src/pages/faq.js +96 -0
  186. package/docs/src/pages/getting-started.js +106 -0
  187. package/docs/src/pages/guard.js +121 -0
  188. package/docs/src/pages/home.js +401 -0
  189. package/docs/src/pages/how-it-works.js +183 -0
  190. package/docs/src/pages/hydration.js +98 -0
  191. package/docs/src/pages/images.js +121 -0
  192. package/docs/src/pages/meta.js +120 -0
  193. package/docs/src/pages/mutations.js +106 -0
  194. package/docs/src/pages/navigation.js +85 -0
  195. package/docs/src/pages/performance.js +157 -0
  196. package/docs/src/pages/persist.js +88 -0
  197. package/docs/src/pages/project-structure.js +90 -0
  198. package/docs/src/pages/prompt-examples.js +186 -0
  199. package/docs/src/pages/raw-responses.js +124 -0
  200. package/docs/src/pages/routing.js +99 -0
  201. package/docs/src/pages/server-api.js +281 -0
  202. package/docs/src/pages/server-data.js +185 -0
  203. package/docs/src/pages/slash-commands.js +55 -0
  204. package/docs/src/pages/spec.js +207 -0
  205. package/docs/src/pages/state.js +101 -0
  206. package/docs/src/pages/store.js +181 -0
  207. package/docs/src/pages/streaming.js +108 -0
  208. package/docs/src/pages/stripe.js +193 -0
  209. package/docs/src/pages/supabase.js +323 -0
  210. package/docs/src/pages/testing.js +198 -0
  211. package/docs/src/pages/validation.js +138 -0
  212. package/examples/contact.js +166 -0
  213. package/examples/counter.js +94 -0
  214. package/examples/dev.server.js +91 -0
  215. package/examples/examples.test.js +394 -0
  216. package/examples/pricing.js +244 -0
  217. package/examples/products.js +191 -0
  218. package/examples/quiz.js +208 -0
  219. package/examples/shared.js +78 -0
  220. package/examples/todos.js +162 -0
  221. package/package.json +75 -0
  222. package/public/.pulse-ui-version +1 -0
  223. package/public/chippy-bird.css +246 -0
  224. package/public/examples/contact.css +119 -0
  225. package/public/examples/counter.css +79 -0
  226. package/public/examples/pricing.css +132 -0
  227. package/public/examples/products.css +100 -0
  228. package/public/examples/quiz.css +200 -0
  229. package/public/examples/todos.css +137 -0
  230. package/public/favicon.ico +0 -0
  231. package/public/log-dashboard.css +383 -0
  232. package/public/pulse-ui.css +2740 -0
  233. package/public/pulse-ui.js +236 -0
  234. package/public/pulse.css +149 -0
  235. package/scripts/build.js +411 -0
  236. package/src/agent/checklist.md +111 -0
  237. package/src/agent/coverage-check.js +66 -0
  238. package/src/agent/guide-components.md +274 -0
  239. package/src/agent/guide-examples.md +54 -0
  240. package/src/agent/guide-routing.md +36 -0
  241. package/src/agent/guide-server.md +258 -0
  242. package/src/agent/guide-spec.md +103 -0
  243. package/src/agent/guide-styles.md +191 -0
  244. package/src/agent/guide.md +979 -0
  245. package/src/agent/identity.md +106 -0
  246. package/src/agent/workflow.md +108 -0
  247. package/src/cli/cli.test.js +82 -0
  248. package/src/cli/dev.js +195 -0
  249. package/src/cli/discover.js +113 -0
  250. package/src/cli/index.js +361 -0
  251. package/src/cli/load-report.js +91 -0
  252. package/src/cli/load-runner.js +121 -0
  253. package/src/cli/report-server.js +723 -0
  254. package/src/cli/report.js +116 -0
  255. package/src/cli/scaffold.archive.js +1371 -0
  256. package/src/cli/scaffold.js +349 -0
  257. package/src/cli/start.js +74 -0
  258. package/src/html.js +19 -0
  259. package/src/mcp/server.js +884 -0
  260. package/src/mcp/validate-worker.js +110 -0
  261. package/src/runtime/image.js +74 -0
  262. package/src/runtime/image.test.js +111 -0
  263. package/src/runtime/index.js +621 -0
  264. package/src/runtime/navigate.js +146 -0
  265. package/src/runtime/runtime.test.js +773 -0
  266. package/src/runtime/ssr.js +464 -0
  267. package/src/runtime/ssr.test.js +421 -0
  268. package/src/runtime/store.js +92 -0
  269. package/src/runtime/toast.js +163 -0
  270. package/src/server/index.js +1386 -0
  271. package/src/server/server.test.js +1248 -0
  272. package/src/spec/schema.js +428 -0
  273. package/src/spec/schema.test.js +291 -0
  274. package/src/store/index.js +102 -0
  275. package/src/store/store.test.js +210 -0
  276. package/src/testing/html.js +283 -0
  277. package/src/testing/index.js +249 -0
  278. package/src/testing/testing.test.js +450 -0
  279. package/src/ui/accordion.js +28 -0
  280. package/src/ui/alert.js +43 -0
  281. package/src/ui/app-badge.js +48 -0
  282. package/src/ui/avatar.js +47 -0
  283. package/src/ui/badge.js +24 -0
  284. package/src/ui/banner.js +26 -0
  285. package/src/ui/breadcrumbs.js +38 -0
  286. package/src/ui/button.js +66 -0
  287. package/src/ui/card.js +34 -0
  288. package/src/ui/carousel.js +59 -0
  289. package/src/ui/charts.js +321 -0
  290. package/src/ui/checkbox.js +65 -0
  291. package/src/ui/cluster.js +44 -0
  292. package/src/ui/code-window.js +39 -0
  293. package/src/ui/container.js +24 -0
  294. package/src/ui/cta.js +37 -0
  295. package/src/ui/divider.js +29 -0
  296. package/src/ui/empty.js +33 -0
  297. package/src/ui/feature.js +33 -0
  298. package/src/ui/fieldset.js +37 -0
  299. package/src/ui/fileupload.js +89 -0
  300. package/src/ui/footer.js +38 -0
  301. package/src/ui/grid.js +36 -0
  302. package/src/ui/heading.js +45 -0
  303. package/src/ui/hero.js +37 -0
  304. package/src/ui/icons.js +161 -0
  305. package/src/ui/index.js +89 -0
  306. package/src/ui/input.js +74 -0
  307. package/src/ui/list.js +36 -0
  308. package/src/ui/media.js +44 -0
  309. package/src/ui/modal.js +80 -0
  310. package/src/ui/nav.js +61 -0
  311. package/src/ui/pricing.js +56 -0
  312. package/src/ui/progress.js +62 -0
  313. package/src/ui/prose.js +29 -0
  314. package/src/ui/pullquote.js +34 -0
  315. package/src/ui/radio.js +102 -0
  316. package/src/ui/rating.js +93 -0
  317. package/src/ui/search.js +77 -0
  318. package/src/ui/section.js +69 -0
  319. package/src/ui/segmented.js +50 -0
  320. package/src/ui/select.js +77 -0
  321. package/src/ui/slider.js +84 -0
  322. package/src/ui/spinner.js +34 -0
  323. package/src/ui/stack.js +36 -0
  324. package/src/ui/stat.js +52 -0
  325. package/src/ui/stepper.js +46 -0
  326. package/src/ui/switch.js +57 -0
  327. package/src/ui/table.js +45 -0
  328. package/src/ui/testimonial.js +48 -0
  329. package/src/ui/textarea.js +72 -0
  330. package/src/ui/timeline.js +72 -0
  331. package/src/ui/tooltip.js +28 -0
  332. package/src/ui/ui.test.js +1241 -0
  333. package/src/ui/uiimage.js +65 -0
  334. package/tsconfig.json +13 -0
  335. package/types/html.d.ts +17 -0
  336. package/types/image.d.ts +70 -0
  337. package/types/index.d.ts +7 -0
  338. package/types/navigate.d.ts +38 -0
  339. package/types/runtime.d.ts +63 -0
  340. package/types/schema.d.ts +243 -0
  341. package/types/server.d.ts +145 -0
  342. package/types/ssr.d.ts +110 -0
  343. package/types/testing.d.ts +154 -0
  344. package/types/ui.d.ts +704 -0
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * Pulse — HTTP Server tests
3
+ * run: node src/server/server.test.js
4
+ */
5
+
6
+ import http from 'http'
7
+ import { createServer } from './index.js'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Test runner
11
+ // ---------------------------------------------------------------------------
12
+
13
+ let passed = 0
14
+ let failed = 0
15
+
16
+ async function test(label, fn) {
17
+ try {
18
+ await fn()
19
+ console.log(` ✓ ${label}`)
20
+ passed++
21
+ } catch (e) {
22
+ console.log(` ✗ ${label}`)
23
+ console.log(` ${e.message}`)
24
+ failed++
25
+ }
26
+ }
27
+
28
+ function assert(condition, msg) {
29
+ if (!condition) throw new Error(msg || 'Assertion failed')
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // HTTP helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function get(port, path) {
37
+ return new Promise((resolve, reject) => {
38
+ const req = http.get(`http://localhost:${port}${path}`, (res) => {
39
+ let body = ''
40
+ res.on('data', chunk => { body += chunk })
41
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
42
+ })
43
+ req.on('error', reject)
44
+ })
45
+ }
46
+
47
+ function request(port, method, path, headers = {}) {
48
+ return new Promise((resolve, reject) => {
49
+ const req = http.request(
50
+ { method, hostname: 'localhost', port, path, headers },
51
+ (res) => {
52
+ let body = ''
53
+ res.on('data', c => { body += c })
54
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
55
+ }
56
+ )
57
+ req.on('error', reject)
58
+ req.end()
59
+ })
60
+ }
61
+
62
+ function requestWithBody(port, method, path, body, contentType = 'application/json') {
63
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body)
64
+ return new Promise((resolve, reject) => {
65
+ const req = http.request(
66
+ {
67
+ method,
68
+ hostname: 'localhost',
69
+ port,
70
+ path,
71
+ headers: { 'Content-Type': contentType, 'Content-Length': buf.length }
72
+ },
73
+ (res) => {
74
+ let resBody = ''
75
+ res.on('data', c => { resBody += c })
76
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: resBody }))
77
+ }
78
+ )
79
+ req.on('error', reject)
80
+ req.write(buf)
81
+ req.end()
82
+ })
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Specs used across tests
87
+ // ---------------------------------------------------------------------------
88
+
89
+ const helloSpec = {
90
+ route: '/hello',
91
+ state: {},
92
+ view: () => '<h1>Hello world</h1>'
93
+ }
94
+
95
+ const productSpec = {
96
+ route: '/products/:id',
97
+ state: {},
98
+ server: {
99
+ product: async (ctx) => ({ id: ctx.params.id, name: 'Widget' })
100
+ },
101
+ view: (_s, server) => `<h1>${server.product.name} (${server.product.id})</h1>`
102
+ }
103
+
104
+ const querySpec = {
105
+ route: '/search',
106
+ state: {},
107
+ server: {
108
+ results: async (ctx) => [`Result for: ${ctx.query.q || 'none'}`]
109
+ },
110
+ view: (_s, server) => `<p>${server.results[0]}</p>`
111
+ }
112
+
113
+ const cookieSpec = {
114
+ route: '/profile',
115
+ state: {},
116
+ server: {
117
+ user: async (ctx) => ctx.cookies.user || 'anonymous'
118
+ },
119
+ view: (_s, server) => `<p>${server.user}</p>`
120
+ }
121
+
122
+ const metaSpec = {
123
+ route: '/about',
124
+ state: {},
125
+ view: () => '<p>About</p>',
126
+ meta: { title: 'About Us', description: 'Learn more' }
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Server lifecycle helper — unique port per test to avoid TCP TIME_WAIT
131
+ // ---------------------------------------------------------------------------
132
+
133
+ let nextPort = 13337
134
+
135
+ async function withServer(specs, options, fn) {
136
+ const port = nextPort++
137
+ const { server } = createServer(specs, { ...options, port })
138
+ await new Promise(resolve => server.once('listening', resolve))
139
+ try {
140
+ await fn(port)
141
+ } finally {
142
+ server.closeAllConnections?.()
143
+ await new Promise(resolve => server.close(resolve))
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+
149
+ console.log('\nRoute matching\n')
150
+
151
+ await test('serves a registered route', async () => {
152
+ await withServer([helloSpec], { stream: false }, async (port) => {
153
+ const { status, body } = await get(port, '/hello')
154
+ assert(status === 200, `Expected 200, got ${status}`)
155
+ assert(body.includes('<h1>Hello world</h1>'), `Missing content: ${body}`)
156
+ })
157
+ })
158
+
159
+ await test('returns 404 for unknown route', async () => {
160
+ await withServer([helloSpec], { stream: false }, async (port) => {
161
+ const { status, body } = await get(port, '/nope')
162
+ assert(status === 404, `Expected 404, got ${status}`)
163
+ assert(body.includes('404'), `Expected 404 page: ${body}`)
164
+ })
165
+ })
166
+
167
+ await test('extracts route params', async () => {
168
+ await withServer([productSpec], { stream: false }, async (port) => {
169
+ const { status, body } = await get(port, '/products/42')
170
+ assert(status === 200, `Expected 200, got ${status}`)
171
+ assert(body.includes('Widget (42)'), `Expected param in output: ${body}`)
172
+ })
173
+ })
174
+
175
+ await test('serves multiple routes', async () => {
176
+ await withServer([helloSpec, productSpec], { stream: false }, async (port) => {
177
+ const r1 = await get(port, '/hello')
178
+ const r2 = await get(port, '/products/99')
179
+ assert(r1.body.includes('Hello world'), `Route 1 failed: ${r1.body}`)
180
+ assert(r2.body.includes('Widget (99)'), `Route 2 failed: ${r2.body}`)
181
+ })
182
+ })
183
+
184
+ // ---------------------------------------------------------------------------
185
+
186
+ console.log('\nRequest context\n')
187
+
188
+ await test('passes query string to server fetcher', async () => {
189
+ await withServer([querySpec], { stream: false }, async (port) => {
190
+ const { body } = await get(port, '/search?q=pulse')
191
+ assert(body.includes('Result for: pulse'), `Expected query result: ${body}`)
192
+ })
193
+ })
194
+
195
+ await test('parses cookies from Cookie header', async () => {
196
+ await withServer([cookieSpec], { stream: false }, async (port) => {
197
+ const { body } = await request(port, 'GET', '/profile', { Cookie: 'user=andy' })
198
+ assert(body.includes('andy'), `Expected cookie value in output: ${body}`)
199
+ })
200
+ })
201
+
202
+ await test('returns anonymous when no cookie set', async () => {
203
+ await withServer([cookieSpec], { stream: false }, async (port) => {
204
+ const { body } = await get(port, '/profile')
205
+ assert(body.includes('anonymous'), `Expected anonymous: ${body}`)
206
+ })
207
+ })
208
+
209
+ // ---------------------------------------------------------------------------
210
+
211
+ console.log('\nResponse — string mode\n')
212
+
213
+ await test('wraps content in full HTML document', async () => {
214
+ await withServer([helloSpec], { stream: false }, async (port) => {
215
+ const { body } = await get(port, '/hello')
216
+ assert(body.includes('<!DOCTYPE html>'), `Missing DOCTYPE: ${body}`)
217
+ assert(body.includes('</html>'), `Missing closing tag: ${body}`)
218
+ })
219
+ })
220
+
221
+ await test('sets title from spec.meta', async () => {
222
+ await withServer([metaSpec], { stream: false }, async (port) => {
223
+ const { body } = await get(port, '/about')
224
+ assert(body.includes('<title>About Us</title>'), `Expected title: ${body}`)
225
+ })
226
+ })
227
+
228
+ await test('injects JSON-LD script when meta.schema is set (string mode)', async () => {
229
+ const spec = {
230
+ route: '/article',
231
+ state: {},
232
+ view: () => '<p>Article</p>',
233
+ meta: {
234
+ title: 'An Article',
235
+ schema: { '@context': 'https://schema.org', '@type': 'Article', headline: 'An Article' }
236
+ }
237
+ }
238
+ await withServer([spec], { stream: false }, async (port) => {
239
+ const { body } = await get(port, '/article')
240
+ assert(body.includes('application/ld+json'), `Expected ld+json script: ${body.slice(0, 300)}`)
241
+ assert(body.includes('schema.org'), `Expected schema.org context`)
242
+ assert(body.includes('"Article"'), `Expected @type Article`)
243
+ })
244
+ })
245
+
246
+ await test('injects JSON-LD script when meta.schema is set (stream mode)', async () => {
247
+ const spec = {
248
+ route: '/article-stream',
249
+ state: {},
250
+ view: () => '<p>Article</p>',
251
+ meta: {
252
+ title: 'An Article',
253
+ schema: { '@context': 'https://schema.org', '@type': 'Article', headline: 'An Article' }
254
+ }
255
+ }
256
+ await withServer([spec], { stream: true }, async (port) => {
257
+ const { body } = await get(port, '/article-stream')
258
+ assert(body.includes('application/ld+json'), `Expected ld+json in stream: ${body.slice(0, 300)}`)
259
+ assert(body.includes('schema.org'), `Expected schema.org context in stream`)
260
+ })
261
+ })
262
+
263
+ await test('sets Content-Type to text/html', async () => {
264
+ await withServer([helloSpec], { stream: false }, async (port) => {
265
+ const { headers } = await get(port, '/hello')
266
+ assert(headers['content-type']?.includes('text/html'), `Expected text/html: ${headers['content-type']}`)
267
+ })
268
+ })
269
+
270
+ await test('sets Server-Timing header', async () => {
271
+ await withServer([helloSpec], { stream: false }, async (port) => {
272
+ const { headers } = await get(port, '/hello')
273
+ assert(headers['server-timing'], `Expected Server-Timing header`)
274
+ assert(headers['server-timing'].includes('total'), `Expected total in Server-Timing: ${headers['server-timing']}`)
275
+ })
276
+ })
277
+
278
+ // ---------------------------------------------------------------------------
279
+
280
+ console.log('\nResponse — stream mode\n')
281
+
282
+ await test('streams content with Transfer-Encoding: chunked', async () => {
283
+ await withServer([helloSpec], { stream: true }, async (port) => {
284
+ const { headers, body } = await get(port, '/hello')
285
+ assert(headers['transfer-encoding'] === 'chunked', `Expected chunked: ${headers['transfer-encoding']}`)
286
+ assert(body.includes('<h1>Hello world</h1>'), `Missing content: ${body}`)
287
+ })
288
+ })
289
+
290
+ await test('stream response includes pulse-root div', async () => {
291
+ await withServer([helloSpec], { stream: true }, async (port) => {
292
+ const { body } = await get(port, '/hello')
293
+ assert(body.includes('pulse-root'), `Missing pulse-root: ${body}`)
294
+ })
295
+ })
296
+
297
+ await test('stream response includes timing script', async () => {
298
+ await withServer([helloSpec], { stream: true }, async (port) => {
299
+ const { body } = await get(port, '/hello')
300
+ assert(body.includes('__PULSE_TIMING__'), `Missing timing script: ${body}`)
301
+ })
302
+ })
303
+
304
+ // ---------------------------------------------------------------------------
305
+
306
+ console.log('\nMethod handling\n')
307
+
308
+ await test('rejects non-GET requests with 405', async () => {
309
+ await withServer([helloSpec], { stream: false }, async (port) => {
310
+ const { status } = await request(port, 'POST', '/hello')
311
+ assert(status === 405, `Expected 405, got ${status}`)
312
+ })
313
+ })
314
+
315
+ // ---------------------------------------------------------------------------
316
+
317
+ console.log('\nStartup validation\n')
318
+
319
+ await test('throws on invalid spec at startup', async () => {
320
+ let threw = false
321
+ try {
322
+ createServer([{ route: '/bad' }], { port: 19999 })
323
+ } catch (e) {
324
+ threw = true
325
+ assert(e.message.includes('/bad'), `Expected route in error: ${e.message}`)
326
+ }
327
+ assert(threw, 'Expected createServer to throw on invalid spec')
328
+ })
329
+
330
+ await test('onRequest hook can short-circuit routing', async () => {
331
+ await withServer([helloSpec], {
332
+ stream: false,
333
+ onRequest: (_req, res) => {
334
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
335
+ res.end('Forbidden')
336
+ return false
337
+ }
338
+ }, async (port) => {
339
+ const { status } = await get(port, '/hello')
340
+ assert(status === 403, `Expected 403, got ${status}`)
341
+ })
342
+ })
343
+
344
+ // ---------------------------------------------------------------------------
345
+
346
+ console.log('\nClient-side navigation\n')
347
+
348
+ const navSpec = {
349
+ route: '/nav-page',
350
+ hydrate: '/examples/nav-page.js',
351
+ state: {},
352
+ meta: { title: 'Nav Page' },
353
+ server: { msg: async () => 'hello from server' },
354
+ view: (_s, server) => `<p>${server.msg}</p>`
355
+ }
356
+
357
+ await test('X-Pulse-Navigate returns JSON', async () => {
358
+ await withServer([navSpec], { stream: false }, async (port) => {
359
+ const { status, headers } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
360
+ assert(status === 200, `Expected 200, got ${status}`)
361
+ assert(headers['content-type']?.includes('application/json'), `Expected JSON: ${headers['content-type']}`)
362
+ })
363
+ })
364
+
365
+ await test('navigation response contains html, title, hydrate', async () => {
366
+ await withServer([navSpec], { stream: false }, async (port) => {
367
+ const { body } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
368
+ const payload = JSON.parse(body)
369
+ assert(typeof payload.html === 'string', `Expected html string`)
370
+ assert(payload.title === 'Nav Page', `Expected title: ${payload.title}`)
371
+ assert(payload.hydrate === '/examples/nav-page.js', `Expected hydrate path: ${payload.hydrate}`)
372
+ })
373
+ })
374
+
375
+ await test('navigation response html contains rendered server data', async () => {
376
+ await withServer([navSpec], { stream: false }, async (port) => {
377
+ const { body } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
378
+ const { html } = JSON.parse(body)
379
+ assert(html.includes('hello from server'), `Expected server data in html: ${html}`)
380
+ })
381
+ })
382
+
383
+ await test('navigation response is not a full HTML document', async () => {
384
+ await withServer([navSpec], { stream: false }, async (port) => {
385
+ const { body } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
386
+ const { html } = JSON.parse(body)
387
+ assert(!html.includes('<!DOCTYPE'), `Nav response should not be a full document: ${html}`)
388
+ })
389
+ })
390
+
391
+ await test('normal request still returns full HTML document', async () => {
392
+ await withServer([navSpec], { stream: false }, async (port) => {
393
+ const { body } = await get(port, '/nav-page')
394
+ assert(body.includes('<!DOCTYPE html>'), `Expected full document: ${body}`)
395
+ })
396
+ })
397
+
398
+ // ---------------------------------------------------------------------------
399
+
400
+ console.log('\nError pages\n')
401
+
402
+ const throwingSpec = {
403
+ route: '/boom',
404
+ state: {},
405
+ view: () => { throw new Error('view exploded') }
406
+ }
407
+
408
+ await test('dev mode returns HTML error page on render error (string mode)', async () => {
409
+ await withServer([throwingSpec], { stream: false, dev: true }, async (port) => {
410
+ const { status, headers, body } = await get(port, '/boom')
411
+ assert(status === 500, `Expected 500, got ${status}`)
412
+ assert(headers['content-type']?.includes('text/html'), `Expected HTML, got ${headers['content-type']}`)
413
+ assert(body.includes('view exploded'), `Expected error message in page: ${body.slice(0, 200)}`)
414
+ assert(body.includes('Stack trace'), `Expected stack trace section in page`)
415
+ })
416
+ })
417
+
418
+ await test('production mode returns generic error page (no stack trace)', async () => {
419
+ await withServer([throwingSpec], { stream: false, dev: false }, async (port) => {
420
+ const { status, body } = await get(port, '/boom')
421
+ assert(status === 500, `Expected 500, got ${status}`)
422
+ assert(!body.includes('view exploded'), `Should not expose error details in prod: ${body.slice(0, 200)}`)
423
+ assert(body.includes('500'), `Expected 500 in body`)
424
+ })
425
+ })
426
+
427
+ await test('dev mode injects error into stream when render throws (stream mode)', async () => {
428
+ await withServer([throwingSpec], { stream: true, dev: true }, async (port) => {
429
+ const { status, body } = await get(port, '/boom')
430
+ assert(status === 200, `Stream mode commits 200 before error, got ${status}`)
431
+ assert(body.includes('view exploded'), `Expected error message injected into stream: ${body.slice(0, 400)}`)
432
+ assert(body.includes('pulse-root'), `Expected pulse-root in partial document`)
433
+ })
434
+ })
435
+
436
+ await test('production stream mode hides error details', async () => {
437
+ await withServer([throwingSpec], { stream: true, dev: false }, async (port) => {
438
+ const { status, body } = await get(port, '/boom')
439
+ assert(status === 200, `Stream mode commits 200 before error, got ${status}`)
440
+ assert(!body.includes('view exploded'), `Should not expose error details in prod stream`)
441
+ assert(body.includes('Something went wrong'), `Expected generic message in prod stream`)
442
+ })
443
+ })
444
+
445
+ // ---------------------------------------------------------------------------
446
+
447
+ console.log('\nRaw content responses\n')
448
+
449
+ const rssSpec = {
450
+ route: '/feed.xml',
451
+ contentType: 'application/rss+xml; charset=utf-8',
452
+ render: () => `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>`
453
+ }
454
+
455
+ const jsonFeedSpec = {
456
+ route: '/api/posts',
457
+ contentType: 'application/json',
458
+ server: { posts: async () => [{ id: 1, title: 'Hello' }] },
459
+ render: (_ctx, server) => JSON.stringify(server.posts)
460
+ }
461
+
462
+ await test('raw spec serves correct Content-Type', async () => {
463
+ await withServer([rssSpec], {}, async (port) => {
464
+ const { status, headers } = await get(port, '/feed.xml')
465
+ assert(status === 200, `Expected 200, got ${status}`)
466
+ assert(headers['content-type'].includes('application/rss+xml'), `Wrong content-type: ${headers['content-type']}`)
467
+ })
468
+ })
469
+
470
+ await test('raw spec serves rendered body', async () => {
471
+ await withServer([rssSpec], {}, async (port) => {
472
+ const { body } = await get(port, '/feed.xml')
473
+ assert(body.includes('<rss'), `Missing RSS content: ${body.slice(0, 100)}`)
474
+ assert(body.includes('<title>Test</title>'), `Missing title: ${body.slice(0, 200)}`)
475
+ })
476
+ })
477
+
478
+ await test('raw spec resolves server data and passes to render', async () => {
479
+ await withServer([jsonFeedSpec], {}, async (port) => {
480
+ const { body, headers } = await get(port, '/api/posts')
481
+ assert(headers['content-type'].includes('application/json'), `Wrong content-type: ${headers['content-type']}`)
482
+ const data = JSON.parse(body)
483
+ assert(Array.isArray(data) && data[0].title === 'Hello', `Unexpected data: ${body}`)
484
+ })
485
+ })
486
+
487
+ await test('raw spec does not return an HTML document', async () => {
488
+ await withServer([rssSpec], {}, async (port) => {
489
+ const { body } = await get(port, '/feed.xml')
490
+ assert(!body.includes('<!DOCTYPE html>'), `Should not wrap in HTML document`)
491
+ assert(!body.includes('<body>'), `Should not include body tag`)
492
+ })
493
+ })
494
+
495
+ await test('raw spec is not intercepted by X-Pulse-Navigate', async () => {
496
+ await withServer([rssSpec], {}, async (port) => {
497
+ const { headers, body } = await request(port, 'GET', '/feed.xml', { 'X-Pulse-Navigate': 'true' })
498
+ assert(headers['content-type'].includes('application/rss+xml'), `Should still serve raw content on nav request`)
499
+ assert(body.includes('<rss'), `Body should be RSS not JSON`)
500
+ })
501
+ })
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Guard
505
+ // ---------------------------------------------------------------------------
506
+
507
+ const guardSpec = {
508
+ route: '/protected',
509
+ meta: { title: 'Protected', styles: [] },
510
+ state: {},
511
+ guard: async (ctx) => {
512
+ if (ctx.headers['x-auth'] !== 'secret') return { redirect: '/login' }
513
+ },
514
+ view: () => `<main id="main-content"><h1>Secret</h1></main>`,
515
+ }
516
+
517
+ await test('guard redirects 302 when condition fails', async () => {
518
+ await withServer([guardSpec], {}, async (port) => {
519
+ const { status, headers } = await request(port, 'GET', '/protected')
520
+ assert(status === 302, `Expected 302, got ${status}`)
521
+ assert(headers.location === '/login', `Expected Location: /login, got ${headers.location}`)
522
+ })
523
+ })
524
+
525
+ await test('guard allows request when condition passes', async () => {
526
+ await withServer([guardSpec], {}, async (port) => {
527
+ const { status, body } = await request(port, 'GET', '/protected', { 'x-auth': 'secret' })
528
+ assert(status === 200, `Expected 200, got ${status}`)
529
+ assert(body.includes('Secret'), `Expected page content, got: ${body.slice(0, 200)}`)
530
+ })
531
+ })
532
+
533
+ await test('guard runs before server data fetchers', async () => {
534
+ let fetcherCalled = false
535
+ const spec = {
536
+ route: '/guarded',
537
+ meta: { title: 'Guarded', styles: [] },
538
+ state: {},
539
+ guard: async () => ({ redirect: '/login' }),
540
+ server: { data: async () => { fetcherCalled = true; return {} } },
541
+ view: () => `<main id="main-content">Content</main>`,
542
+ }
543
+ await withServer([spec], {}, async (port) => {
544
+ await request(port, 'GET', '/guarded')
545
+ assert(!fetcherCalled, 'Server data fetcher should not run when guard redirects')
546
+ })
547
+ })
548
+
549
+ await test('guard redirect includes security headers', async () => {
550
+ await withServer([guardSpec], {}, async (port) => {
551
+ const { headers } = await request(port, 'GET', '/protected')
552
+ assert(headers['x-frame-options'] === 'DENY', `Missing security header`)
553
+ })
554
+ })
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // ctx.setHeader / ctx.setCookie
558
+ // ---------------------------------------------------------------------------
559
+
560
+ const setCtxCookieSpec = {
561
+ route: '/profile',
562
+ meta: { title: 'Profile', styles: [] },
563
+ state: {},
564
+ guard: async (ctx) => {
565
+ ctx.setCookie('visited', 'true', { httpOnly: true, maxAge: 3600 })
566
+ },
567
+ view: () => `<main id="main-content"><h1>Profile</h1></main>`,
568
+ }
569
+
570
+ await test('ctx.setCookie sets Set-Cookie header on page response', async () => {
571
+ await withServer([setCtxCookieSpec], { stream: false }, async (port) => {
572
+ const { status, headers } = await request(port, 'GET', '/profile')
573
+ assert(status === 200, `Expected 200, got ${status}`)
574
+ const cookies = [headers['set-cookie']].flat().filter(Boolean)
575
+ assert(cookies.some(c => c.includes('visited=true')), `Expected visited cookie, got: ${JSON.stringify(cookies)}`)
576
+ assert(cookies.some(c => c.includes('HttpOnly')), `Expected HttpOnly, got: ${JSON.stringify(cookies)}`)
577
+ assert(cookies.some(c => c.includes('Max-Age=3600')), `Expected Max-Age, got: ${JSON.stringify(cookies)}`)
578
+ })
579
+ })
580
+
581
+ await test('ctx.setCookie works on streaming response', async () => {
582
+ await withServer([setCtxCookieSpec], { stream: true }, async (port) => {
583
+ const { status, headers } = await request(port, 'GET', '/profile')
584
+ assert(status === 200, `Expected 200, got ${status}`)
585
+ const cookies = [headers['set-cookie']].flat().filter(Boolean)
586
+ assert(cookies.some(c => c.includes('visited=true')), `Expected visited cookie in stream response`)
587
+ })
588
+ })
589
+
590
+ await test('ctx.setCookie on guard redirect includes cookie in redirect response', async () => {
591
+ const spec = {
592
+ route: '/logout',
593
+ meta: { title: 'Logout', styles: [] },
594
+ state: {},
595
+ guard: async (ctx) => {
596
+ ctx.setCookie('session', '', { maxAge: 0 })
597
+ return { redirect: '/login' }
598
+ },
599
+ view: () => `<main id="main-content">Logged out</main>`,
600
+ }
601
+ await withServer([spec], {}, async (port) => {
602
+ const { status, headers } = await request(port, 'GET', '/logout')
603
+ assert(status === 302, `Expected 302, got ${status}`)
604
+ const cookies = [headers['set-cookie']].flat().filter(Boolean)
605
+ assert(cookies.some(c => c.includes('Max-Age=0')), `Expected expired cookie on redirect`)
606
+ })
607
+ })
608
+
609
+ await test('raw response render can return { redirect } for auth callbacks', async () => {
610
+ const callbackSpec = {
611
+ route: '/auth/callback',
612
+ contentType: 'text/html',
613
+ server: {
614
+ token: async (ctx) => {
615
+ ctx.setCookie('session', 'tok123', { httpOnly: true })
616
+ return 'tok123'
617
+ },
618
+ },
619
+ render: () => ({ redirect: '/dashboard' }),
620
+ }
621
+ await withServer([callbackSpec], {}, async (port) => {
622
+ const { status, headers } = await request(port, 'GET', '/auth/callback')
623
+ assert(status === 302, `Expected 302, got ${status}`)
624
+ assert(headers.location === '/dashboard', `Expected /dashboard, got ${headers.location}`)
625
+ const cookies = [headers['set-cookie']].flat().filter(Boolean)
626
+ assert(cookies.some(c => c.includes('session=tok123')), `Expected session cookie on redirect`)
627
+ })
628
+ })
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Canonical URLs + trailing slash redirects
632
+ // ---------------------------------------------------------------------------
633
+
634
+ const canonicalSpec = {
635
+ route: '/about',
636
+ meta: { title: 'About', styles: [] },
637
+ state: {},
638
+ view: () => `<main id="main-content"><h1>About</h1></main>`,
639
+ }
640
+
641
+ const canonicalOverrideSpec = {
642
+ route: '/about',
643
+ meta: { title: 'About', styles: [], canonical: 'https://example.com/about' },
644
+ state: {},
645
+ view: () => `<main id="main-content"><h1>About</h1></main>`,
646
+ }
647
+
648
+ await test('trailing slash redirects 301 to path without slash', async () => {
649
+ await withServer([canonicalSpec], {}, async (port) => {
650
+ const { status, headers } = await request(port, 'GET', '/about/')
651
+ assert(status === 301, `Expected 301, got ${status}`)
652
+ assert(headers.location === '/about', `Expected Location: /about, got ${headers.location}`)
653
+ })
654
+ })
655
+
656
+ await test('trailing slash redirect preserves query string', async () => {
657
+ await withServer([canonicalSpec], {}, async (port) => {
658
+ const { status, headers } = await request(port, 'GET', '/about/?foo=bar')
659
+ assert(status === 301, `Expected 301, got ${status}`)
660
+ assert(headers.location === '/about?foo=bar', `Expected query preserved, got ${headers.location}`)
661
+ })
662
+ })
663
+
664
+ await test('root path does not redirect', async () => {
665
+ const homeSpec = { route: '/', meta: { title: 'Home', styles: [] }, state: {}, view: () => `<main id="main-content">Home</main>` }
666
+ await withServer([homeSpec], {}, async (port) => {
667
+ const { status } = await request(port, 'GET', '/')
668
+ assert(status === 200, `Root / should not redirect, got ${status}`)
669
+ })
670
+ })
671
+
672
+ await test('canonical tag injected with request host and path', async () => {
673
+ await withServer([canonicalSpec], { stream: false }, async (port) => {
674
+ const { body } = await get(port, '/about')
675
+ assert(body.includes(`<link rel="canonical" href="http://localhost:${port}/about">`),
676
+ `Missing canonical tag in: ${body.slice(0, 500)}`)
677
+ })
678
+ })
679
+
680
+ await test('meta.canonical overrides auto-generated canonical URL', async () => {
681
+ await withServer([canonicalOverrideSpec], { stream: false }, async (port) => {
682
+ const { body } = await get(port, '/about')
683
+ assert(body.includes(`<link rel="canonical" href="https://example.com/about">`),
684
+ `Expected override canonical, got: ${body.slice(0, 500)}`)
685
+ })
686
+ })
687
+
688
+ await test('canonical tag injected in streaming response', async () => {
689
+ await withServer([canonicalSpec], { stream: true }, async (port) => {
690
+ const { body } = await get(port, '/about')
691
+ assert(body.includes(`<link rel="canonical" href="http://localhost:${port}/about">`),
692
+ `Missing canonical tag in stream response: ${body.slice(0, 500)}`)
693
+ })
694
+ })
695
+
696
+ // trailingSlash: 'add'
697
+ await test("trailingSlash:'add' redirects /about to /about/", async () => {
698
+ await withServer([canonicalSpec], { trailingSlash: 'add' }, async (port) => {
699
+ const { status, headers } = await request(port, 'GET', '/about')
700
+ assert(status === 301, `Expected 301, got ${status}`)
701
+ assert(headers.location === '/about/', `Expected Location: /about/, got ${headers.location}`)
702
+ })
703
+ })
704
+
705
+ await test("trailingSlash:'add' does not redirect /about/", async () => {
706
+ await withServer([canonicalSpec], { trailingSlash: 'add' }, async (port) => {
707
+ const { status } = await request(port, 'GET', '/about/')
708
+ assert(status === 200, `Expected 200, got ${status}`)
709
+ })
710
+ })
711
+
712
+ await test("trailingSlash:'add' redirect preserves query string", async () => {
713
+ await withServer([canonicalSpec], { trailingSlash: 'add' }, async (port) => {
714
+ const { status, headers } = await request(port, 'GET', '/about?foo=bar')
715
+ assert(status === 301, `Expected 301, got ${status}`)
716
+ assert(headers.location === '/about/?foo=bar', `Expected query preserved, got ${headers.location}`)
717
+ })
718
+ })
719
+
720
+ await test("trailingSlash:'add' root does not redirect", async () => {
721
+ const homeSpec = { route: '/', meta: { title: 'Home', styles: [] }, state: {}, view: () => `<main id="main-content">Home</main>` }
722
+ await withServer([homeSpec], { trailingSlash: 'add' }, async (port) => {
723
+ const { status } = await request(port, 'GET', '/')
724
+ assert(status === 200, `Root / should not redirect, got ${status}`)
725
+ })
726
+ })
727
+
728
+ await test("trailingSlash:'add' canonical tag uses slash form", async () => {
729
+ await withServer([canonicalSpec], { trailingSlash: 'add', stream: false }, async (port) => {
730
+ const { body } = await get(port, '/about/')
731
+ assert(body.includes(`<link rel="canonical" href="http://localhost:${port}/about/">`),
732
+ `Expected slash canonical, got: ${body.slice(0, 500)}`)
733
+ })
734
+ })
735
+
736
+ // trailingSlash: 'allow'
737
+ await test("trailingSlash:'allow' serves /about/ without redirect", async () => {
738
+ await withServer([canonicalSpec], { trailingSlash: 'allow' }, async (port) => {
739
+ const { status } = await request(port, 'GET', '/about/')
740
+ assert(status === 200, `Expected 200, got ${status}`)
741
+ })
742
+ })
743
+
744
+ await test("trailingSlash:'allow' serves /about without redirect", async () => {
745
+ await withServer([canonicalSpec], { trailingSlash: 'allow' }, async (port) => {
746
+ const { status } = await request(port, 'GET', '/about')
747
+ assert(status === 200, `Expected 200, got ${status}`)
748
+ })
749
+ })
750
+
751
+ await test("trailingSlash:'allow' canonical uses no-slash form for both paths", async () => {
752
+ await withServer([canonicalSpec], { trailingSlash: 'allow', stream: false }, async (port) => {
753
+ const { body: bodySlash } = await get(port, '/about/')
754
+ const { body: bodyNoSlash } = await get(port, '/about')
755
+ const tag = `<link rel="canonical" href="http://localhost:${port}/about">`
756
+ assert(bodySlash.includes(tag), `Expected no-slash canonical for /about/, got: ${bodySlash.slice(0, 500)}`)
757
+ assert(bodyNoSlash.includes(tag), `Expected no-slash canonical for /about, got: ${bodyNoSlash.slice(0, 500)}`)
758
+ })
759
+ })
760
+
761
+ // ---------------------------------------------------------------------------
762
+ // Security — CSP, HSTS, SameSite, POST support
763
+ // ---------------------------------------------------------------------------
764
+
765
+ // CSP
766
+ await test('CSP header present on page response', async () => {
767
+ await withServer([canonicalSpec], { stream: false }, async (port) => {
768
+ const { headers } = await request(port, 'GET', '/about')
769
+ assert(headers['content-security-policy'],
770
+ 'Missing Content-Security-Policy header')
771
+ assert(headers['content-security-policy'].includes("object-src 'none'"),
772
+ `Expected object-src 'none' in CSP: ${headers['content-security-policy']}`)
773
+ })
774
+ })
775
+
776
+ await test('CSP nonce is present in page response body', async () => {
777
+ const hydrateSpec = {
778
+ route: '/about',
779
+ meta: { title: 'About', styles: [] },
780
+ state: {},
781
+ hydrate: '/examples/about.js',
782
+ view: () => `<main id="main-content"><h1>About</h1></main>`,
783
+ }
784
+ await withServer([hydrateSpec], { stream: false }, async (port) => {
785
+ const { headers, body } = await request(port, 'GET', '/about')
786
+ const csp = headers['content-security-policy']
787
+ const match = csp && csp.match(/nonce-([A-Za-z0-9_\-]+)/)
788
+ assert(match, `Expected nonce in CSP header: ${csp}`)
789
+ const nonce = match[1]
790
+ assert(body.includes(`nonce="${nonce}"`),
791
+ `Expected nonce="${nonce}" in HTML body`)
792
+ })
793
+ })
794
+
795
+ await test('CSP nonce differs between requests', async () => {
796
+ await withServer([canonicalSpec], { stream: false }, async (port) => {
797
+ const r1 = await request(port, 'GET', '/about')
798
+ const r2 = await request(port, 'GET', '/about')
799
+ const n1 = r1.headers['content-security-policy']?.match(/nonce-([A-Za-z0-9_\-]+)/)?.[1]
800
+ const n2 = r2.headers['content-security-policy']?.match(/nonce-([A-Za-z0-9_\-]+)/)?.[1]
801
+ assert(n1 && n2 && n1 !== n2, `Expected different nonces per request, got: ${n1} and ${n2}`)
802
+ })
803
+ })
804
+
805
+ await test('CSP header present on streaming page response', async () => {
806
+ await withServer([canonicalSpec], { stream: true }, async (port) => {
807
+ const { headers } = await request(port, 'GET', '/about')
808
+ assert(headers['content-security-policy'],
809
+ 'Missing Content-Security-Policy header on streaming response')
810
+ })
811
+ })
812
+
813
+ // HSTS
814
+ await test('HSTS header absent on plain HTTP', async () => {
815
+ await withServer([canonicalSpec], { stream: false }, async (port) => {
816
+ const { headers } = await request(port, 'GET', '/about')
817
+ assert(!headers['strict-transport-security'],
818
+ `HSTS should not be present on plain HTTP, got: ${headers['strict-transport-security']}`)
819
+ })
820
+ })
821
+
822
+ await test('HSTS header present when x-forwarded-proto is https', async () => {
823
+ await withServer([canonicalSpec], { stream: false }, async (port) => {
824
+ const { headers } = await request(port, 'GET', '/about', { 'x-forwarded-proto': 'https' })
825
+ assert(headers['strict-transport-security'],
826
+ 'Expected Strict-Transport-Security header when x-forwarded-proto: https')
827
+ assert(headers['strict-transport-security'].includes('max-age=31536000'),
828
+ `Expected max-age=31536000 in HSTS: ${headers['strict-transport-security']}`)
829
+ })
830
+ })
831
+
832
+ // SameSite default
833
+ await test('setCookie defaults to SameSite=Lax', async () => {
834
+ const cookieSpec = {
835
+ route: '/cookie-test',
836
+ state: {},
837
+ guard: async (ctx) => {
838
+ ctx.setCookie('test', 'value', { httpOnly: true })
839
+ },
840
+ view: () => `<main id="main-content">ok</main>`,
841
+ }
842
+ await withServer([cookieSpec], { stream: false }, async (port) => {
843
+ const { headers } = await request(port, 'GET', '/cookie-test')
844
+ const cookie = Array.isArray(headers['set-cookie'])
845
+ ? headers['set-cookie'].join('; ')
846
+ : headers['set-cookie'] || ''
847
+ assert(cookie.includes('SameSite=Lax'),
848
+ `Expected SameSite=Lax default, got: ${cookie}`)
849
+ })
850
+ })
851
+
852
+ await test('setCookie SameSite can be overridden to Strict', async () => {
853
+ const cookieSpec2 = {
854
+ route: '/cookie-strict',
855
+ state: {},
856
+ guard: async (ctx) => {
857
+ ctx.setCookie('test', 'value', { sameSite: 'Strict' })
858
+ },
859
+ view: () => `<main id="main-content">ok</main>`,
860
+ }
861
+ await withServer([cookieSpec2], { stream: false }, async (port) => {
862
+ const { headers } = await request(port, 'GET', '/cookie-strict')
863
+ const cookie = Array.isArray(headers['set-cookie'])
864
+ ? headers['set-cookie'].join('; ')
865
+ : headers['set-cookie'] || ''
866
+ assert(cookie.includes('SameSite=Strict'),
867
+ `Expected SameSite=Strict override, got: ${cookie}`)
868
+ })
869
+ })
870
+
871
+ // POST support for raw response specs
872
+ await test('POST request to raw response spec returns 200', async () => {
873
+ const postSpec = {
874
+ route: '/webhook',
875
+ contentType: 'application/json',
876
+ render: () => JSON.stringify({ received: true }),
877
+ }
878
+ await withServer([postSpec], {}, async (port) => {
879
+ const { status } = await request(port, 'POST', '/webhook')
880
+ assert(status === 200, `Expected 200 for POST to raw spec, got ${status}`)
881
+ })
882
+ })
883
+
884
+ await test('POST request to page spec returns 405', async () => {
885
+ await withServer([canonicalSpec], {}, async (port) => {
886
+ const { status } = await request(port, 'POST', '/about')
887
+ assert(status === 405, `Expected 405 for POST to page spec, got ${status}`)
888
+ })
889
+ })
890
+
891
+ // Body parsing
892
+
893
+ await test('ctx.json() parses JSON POST body', async () => {
894
+ const spec = {
895
+ route: '/api/items',
896
+ methods: ['GET', 'POST'],
897
+ state: {},
898
+ guard: async (ctx) => {
899
+ if (ctx.method === 'POST') {
900
+ const data = await ctx.json()
901
+ return { status: 201, json: { created: data.name } }
902
+ }
903
+ },
904
+ view: () => '<p>items</p>',
905
+ }
906
+ await withServer([spec], { stream: false }, async (port) => {
907
+ const { status, body } = await requestWithBody(port, 'POST', '/api/items', '{"name":"widget"}', 'application/json')
908
+ assert(status === 201, `Expected 201, got ${status}`)
909
+ assert(JSON.parse(body).created === 'widget', `Expected created:widget, got ${body}`)
910
+ })
911
+ })
912
+
913
+ await test('ctx.formData() parses URL-encoded POST body', async () => {
914
+ const spec = {
915
+ route: '/contact',
916
+ methods: ['GET', 'POST'],
917
+ state: {},
918
+ guard: async (ctx) => {
919
+ if (ctx.method === 'POST') {
920
+ const data = await ctx.formData()
921
+ return { redirect: `/thanks?name=${encodeURIComponent(data.name)}` }
922
+ }
923
+ },
924
+ view: () => '<form method="POST"><input name="name"><button>Send</button></form>',
925
+ }
926
+ await withServer([spec], { stream: false }, async (port) => {
927
+ const { status, headers } = await requestWithBody(port, 'POST', '/contact', 'name=Alice', 'application/x-www-form-urlencoded')
928
+ assert(status === 302, `Expected 302, got ${status}`)
929
+ assert(headers.location?.includes('Alice'), `Expected redirect with Alice, got ${headers.location}`)
930
+ })
931
+ })
932
+
933
+ await test('ctx.text() returns raw POST body string', async () => {
934
+ const spec = {
935
+ route: '/webhook',
936
+ contentType: 'application/json',
937
+ render: async (ctx) => {
938
+ const raw = await ctx.text()
939
+ return JSON.stringify({ received: raw })
940
+ },
941
+ }
942
+ await withServer([spec], { stream: false }, async (port) => {
943
+ const { status, body } = await requestWithBody(port, 'POST', '/webhook', 'hello world', 'text/plain')
944
+ assert(status === 200, `Expected 200, got ${status}`)
945
+ assert(JSON.parse(body).received === 'hello world', `Expected received body, got ${body}`)
946
+ })
947
+ })
948
+
949
+ await test('POST to page spec without methods returns 405', async () => {
950
+ await withServer([helloSpec], { stream: false }, async (port) => {
951
+ const { status } = await request(port, 'POST', '/hello')
952
+ assert(status === 405, `Expected 405, got ${status}`)
953
+ })
954
+ })
955
+
956
+ await test('spec.methods allows POST on page spec', async () => {
957
+ const spec = {
958
+ route: '/form',
959
+ methods: ['GET', 'POST'],
960
+ state: {},
961
+ guard: async (ctx) => {
962
+ if (ctx.method === 'POST') return { redirect: '/done' }
963
+ },
964
+ view: () => '<form method="POST"><button>Go</button></form>',
965
+ }
966
+ await withServer([spec], { stream: false }, async (port) => {
967
+ const { status } = await request(port, 'POST', '/form')
968
+ assert(status === 302, `Expected 302, got ${status}`)
969
+ })
970
+ })
971
+
972
+ await test('guard can return { status, json } for custom error responses', async () => {
973
+ const spec = {
974
+ route: '/api/secure',
975
+ methods: ['GET', 'POST'],
976
+ state: {},
977
+ guard: async (ctx) => {
978
+ if (ctx.method === 'POST') {
979
+ const data = await ctx.json()
980
+ if (!data?.token) return { status: 401, json: { error: 'Unauthorized' } }
981
+ }
982
+ },
983
+ view: () => '<p>secure</p>',
984
+ }
985
+ await withServer([spec], { stream: false }, async (port) => {
986
+ const { status, body } = await requestWithBody(port, 'POST', '/api/secure', '{}', 'application/json')
987
+ assert(status === 401, `Expected 401, got ${status}`)
988
+ assert(JSON.parse(body).error === 'Unauthorized', `Expected error message, got ${body}`)
989
+ })
990
+ })
991
+
992
+ await test('body exceeding maxBody returns 413', async () => {
993
+ const spec = {
994
+ route: '/upload',
995
+ contentType: 'application/json',
996
+ render: async (ctx) => { await ctx.text(); return '{}' },
997
+ }
998
+ await withServer([spec], { stream: false, maxBody: 10 }, async (port) => {
999
+ const { status } = await requestWithBody(port, 'POST', '/upload', 'x'.repeat(100), 'text/plain')
1000
+ assert(status === 413, `Expected 413, got ${status}`)
1001
+ })
1002
+ })
1003
+
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ console.log('\nHealth check\n')
1007
+
1008
+ await test('GET /healthz returns 200 with JSON status by default', async () => {
1009
+ await withServer([helloSpec], { stream: false }, async (port) => {
1010
+ const { status, headers, body } = await get(port, '/healthz')
1011
+ assert(status === 200, `Expected 200, got ${status}`)
1012
+ assert(headers['content-type']?.includes('application/json'), `Expected JSON content-type, got ${headers['content-type']}`)
1013
+ const json = JSON.parse(body)
1014
+ assert(json.status === 'ok', `Expected status ok, got ${JSON.stringify(json)}`)
1015
+ assert(typeof json.uptime === 'number', `Expected uptime number, got ${JSON.stringify(json)}`)
1016
+ })
1017
+ })
1018
+
1019
+ await test('HEAD /healthz returns 200 with no body', async () => {
1020
+ await withServer([helloSpec], { stream: false }, async (port) => {
1021
+ const { status, body } = await request(port, 'HEAD', '/healthz')
1022
+ assert(status === 200, `Expected 200, got ${status}`)
1023
+ assert(body === '', `Expected empty body for HEAD, got: ${body}`)
1024
+ })
1025
+ })
1026
+
1027
+ await test('health check uses a custom path when set', async () => {
1028
+ await withServer([helloSpec], { stream: false, healthCheck: '/ping' }, async (port) => {
1029
+ const { status } = await get(port, '/ping')
1030
+ assert(status === 200, `Expected 200 at /ping, got ${status}`)
1031
+ const { status: defaultStatus } = await get(port, '/healthz')
1032
+ assert(defaultStatus === 404, `Expected 404 at /healthz when overridden, got ${defaultStatus}`)
1033
+ })
1034
+ })
1035
+
1036
+ await test('health check can be disabled with healthCheck: false', async () => {
1037
+ await withServer([helloSpec], { stream: false, healthCheck: false }, async (port) => {
1038
+ const { status } = await get(port, '/healthz')
1039
+ assert(status === 404, `Expected 404 when healthCheck disabled, got ${status}`)
1040
+ })
1041
+ })
1042
+
1043
+ await test('health check bypasses onRequest hook', async () => {
1044
+ let hookCalled = false
1045
+ await withServer([helloSpec], {
1046
+ stream: false,
1047
+ onRequest: () => { hookCalled = true }
1048
+ }, async (port) => {
1049
+ await get(port, '/healthz')
1050
+ assert(!hookCalled, 'Expected onRequest to NOT be called for health check')
1051
+ })
1052
+ })
1053
+
1054
+ await test('health check fires before route matching — shadows a user spec at the same path', async () => {
1055
+ // Built-in handler is checked before route matching, so a spec at /healthz never runs
1056
+ const customSpec = { route: '/healthz', state: {}, view: () => '<p>custom</p>' }
1057
+ await withServer([customSpec], { stream: false }, async (port) => {
1058
+ const { body } = await get(port, '/healthz')
1059
+ assert(body.includes('"status"'), `Expected health JSON, got: ${body}`)
1060
+ })
1061
+ })
1062
+
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ console.log('\nGraceful shutdown\n')
1066
+
1067
+ await test('shutdown() is returned from createServer', async () => {
1068
+ const port = nextPort++
1069
+ const result = createServer([helloSpec], { port, stream: false })
1070
+ assert(typeof result.shutdown === 'function', 'Expected shutdown to be a function')
1071
+ result.server.closeAllConnections?.()
1072
+ await new Promise(resolve => result.server.close(resolve))
1073
+ })
1074
+
1075
+ await test('shutdown() stops the server from accepting new connections', async () => {
1076
+ const port = nextPort++
1077
+ const { server, shutdown } = createServer([helloSpec], { port, stream: false })
1078
+ await new Promise(resolve => server.once('listening', resolve))
1079
+
1080
+ // Confirm server is up
1081
+ const before = await get(port, '/hello')
1082
+ assert(before.status === 200, `Expected 200 before shutdown, got ${before.status}`)
1083
+
1084
+ shutdown()
1085
+
1086
+ // Server should no longer accept connections
1087
+ await new Promise(resolve => setTimeout(resolve, 20))
1088
+ let refused = false
1089
+ try { await get(port, '/hello') } catch { refused = true }
1090
+ assert(refused, 'Expected connection to be refused after shutdown()')
1091
+ })
1092
+
1093
+ await test('shutdown() is idempotent — calling twice does not throw', async () => {
1094
+ const port = nextPort++
1095
+ const { server, shutdown } = createServer([helloSpec], { port, stream: false })
1096
+ await new Promise(resolve => server.once('listening', resolve))
1097
+ shutdown()
1098
+ shutdown() // second call is a no-op
1099
+ await new Promise(resolve => setTimeout(resolve, 20))
1100
+ })
1101
+
1102
+ await test('in-flight request completes after shutdown() is called', async () => {
1103
+ const port = nextPort++
1104
+ let resolveSlowFetch
1105
+ const slowSpec = {
1106
+ route: '/slow',
1107
+ state: {},
1108
+ server: {
1109
+ data: () => new Promise(resolve => { resolveSlowFetch = () => resolve('done') })
1110
+ },
1111
+ view: (_s, server) => `<p>${server.data}</p>`
1112
+ }
1113
+
1114
+ const { server, shutdown } = createServer([slowSpec], { port, stream: false })
1115
+ await new Promise(resolve => server.once('listening', resolve))
1116
+
1117
+ // Start a request but don't resolve its server fetch yet
1118
+ const pending = get(port, '/slow')
1119
+
1120
+ // Small delay to ensure the request is in flight before shutdown
1121
+ await new Promise(resolve => setTimeout(resolve, 20))
1122
+
1123
+ shutdown()
1124
+
1125
+ // Now resolve the slow fetch — the response should still complete
1126
+ resolveSlowFetch()
1127
+ const { status, body } = await pending
1128
+ assert(status === 200, `Expected 200, got ${status}`)
1129
+ assert(body.includes('<p>done</p>'), `Expected response body, got: ${body}`)
1130
+ })
1131
+
1132
+ // ---------------------------------------------------------------------------
1133
+ // Multipart form data — ctx.formData()
1134
+ // ---------------------------------------------------------------------------
1135
+
1136
+ /**
1137
+ * Build a multipart/form-data body Buffer from a fields map.
1138
+ * Each field can be a string (text field) or
1139
+ * { filename, type, content: string|Buffer } (file field).
1140
+ */
1141
+ function buildMultipart(fields, boundary = 'TestBoundary123') {
1142
+ const chunks = []
1143
+ for (const [name, value] of Object.entries(fields)) {
1144
+ chunks.push(Buffer.from(`--${boundary}\r\n`))
1145
+ if (value !== null && typeof value === 'object' && 'filename' in value) {
1146
+ const ct = value.type || 'application/octet-stream'
1147
+ chunks.push(Buffer.from(`Content-Disposition: form-data; name="${name}"; filename="${value.filename}"\r\nContent-Type: ${ct}\r\n\r\n`))
1148
+ chunks.push(Buffer.isBuffer(value.content) ? value.content : Buffer.from(String(value.content)))
1149
+ } else {
1150
+ chunks.push(Buffer.from(`Content-Disposition: form-data; name="${name}"\r\n\r\n`))
1151
+ chunks.push(Buffer.from(String(value)))
1152
+ }
1153
+ chunks.push(Buffer.from('\r\n'))
1154
+ }
1155
+ chunks.push(Buffer.from(`--${boundary}--\r\n`))
1156
+ return Buffer.concat(chunks)
1157
+ }
1158
+
1159
+ console.log('\nMultipart form data (ctx.formData)\n')
1160
+
1161
+ const multipartBaseSpec = {
1162
+ route: '/upload',
1163
+ contentType: 'application/json',
1164
+ render: async (ctx) => {
1165
+ const data = await ctx.formData()
1166
+ return JSON.stringify(data ?? null)
1167
+ },
1168
+ }
1169
+
1170
+ await test('ctx.formData() parses multipart text fields', async () => {
1171
+ const body = buildMultipart({ name: 'Alice', email: 'alice@example.com' })
1172
+ await withServer([multipartBaseSpec], {}, async (port) => {
1173
+ const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', body, 'multipart/form-data; boundary=TestBoundary123')
1174
+ assert(status === 200, `Expected 200, got ${status}`)
1175
+ const data = JSON.parse(resBody)
1176
+ assert(data.name === 'Alice', `Expected name Alice, got ${JSON.stringify(data)}`)
1177
+ assert(data.email === 'alice@example.com', `Expected email, got ${JSON.stringify(data)}`)
1178
+ })
1179
+ })
1180
+
1181
+ await test('ctx.formData() parses multipart file field', async () => {
1182
+ const body = buildMultipart({
1183
+ avatar: { filename: 'photo.png', type: 'image/png', content: 'PNG_BYTES_HERE' },
1184
+ })
1185
+ await withServer([multipartBaseSpec], {}, async (port) => {
1186
+ const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', body, 'multipart/form-data; boundary=TestBoundary123')
1187
+ assert(status === 200, `Expected 200, got ${status}`)
1188
+ const data = JSON.parse(resBody)
1189
+ assert(data.avatar?.filename === 'photo.png', `Expected filename photo.png, got ${JSON.stringify(data.avatar)}`)
1190
+ assert(data.avatar?.type === 'image/png', `Expected type image/png, got ${JSON.stringify(data.avatar)}`)
1191
+ assert(typeof data.avatar?.size === 'number', `Expected numeric size, got ${JSON.stringify(data.avatar)}`)
1192
+ })
1193
+ })
1194
+
1195
+ await test('ctx.formData() handles mixed text and file fields', async () => {
1196
+ const body = buildMultipart({
1197
+ title: 'My Upload',
1198
+ file: { filename: 'doc.txt', type: 'text/plain', content: 'Hello world' },
1199
+ })
1200
+ await withServer([multipartBaseSpec], {}, async (port) => {
1201
+ const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', body, 'multipart/form-data; boundary=TestBoundary123')
1202
+ assert(status === 200, `Expected 200, got ${status}`)
1203
+ const data = JSON.parse(resBody)
1204
+ assert(data.title === 'My Upload', `Expected title, got ${JSON.stringify(data)}`)
1205
+ assert(data.file?.filename === 'doc.txt', `Expected filename doc.txt, got ${JSON.stringify(data.file)}`)
1206
+ assert(data.file?.size === 11, `Expected size 11, got ${JSON.stringify(data.file)}`)
1207
+ })
1208
+ })
1209
+
1210
+ await test('ctx.formData() handles repeated field name as array', async () => {
1211
+ // Build raw multipart with two fields sharing the same name
1212
+ const boundary = 'MultiRepeat'
1213
+ const raw = Buffer.concat([
1214
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="tag"\r\n\r\none\r\n`),
1215
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="tag"\r\n\r\ntwo\r\n`),
1216
+ Buffer.from(`--${boundary}--\r\n`),
1217
+ ])
1218
+ await withServer([multipartBaseSpec], {}, async (port) => {
1219
+ const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', raw, `multipart/form-data; boundary=${boundary}`)
1220
+ assert(status === 200, `Expected 200, got ${status}`)
1221
+ const data = JSON.parse(resBody)
1222
+ assert(Array.isArray(data.tag), `Expected array for repeated name, got ${JSON.stringify(data.tag)}`)
1223
+ assert(data.tag.includes('one') && data.tag.includes('two'), `Expected both values, got ${JSON.stringify(data.tag)}`)
1224
+ })
1225
+ })
1226
+
1227
+ await test('ctx.formData() returns null for empty body', async () => {
1228
+ await withServer([multipartBaseSpec], {}, async (port) => {
1229
+ const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', '', 'multipart/form-data; boundary=TestBoundary123')
1230
+ assert(status === 200, `Expected 200, got ${status}`)
1231
+ assert(resBody === 'null', `Expected null for empty body, got ${resBody}`)
1232
+ })
1233
+ })
1234
+
1235
+ await test('ctx.formData() still handles URL-encoded body correctly', async () => {
1236
+ await withServer([multipartBaseSpec], {}, async (port) => {
1237
+ const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', 'name=Bob&role=admin', 'application/x-www-form-urlencoded')
1238
+ assert(status === 200, `Expected 200, got ${status}`)
1239
+ const data = JSON.parse(resBody)
1240
+ assert(data.name === 'Bob', `Expected name Bob, got ${JSON.stringify(data)}`)
1241
+ assert(data.role === 'admin', `Expected role admin, got ${JSON.stringify(data)}`)
1242
+ })
1243
+ })
1244
+
1245
+ // ---------------------------------------------------------------------------
1246
+
1247
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
1248
+ if (failed > 0) process.exit(1)