@nutin/cli 2.1.0 → 3.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 (241) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/README.md +14 -6
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +12 -11
  34. package/templates/base/.gitignore.hbs +1 -0
  35. package/templates/base/AGENTS.md.hbs +45 -43
  36. package/templates/base/GETTING_STARTED.md.hbs +32 -23
  37. package/templates/base/README.md.hbs +7 -4
  38. package/templates/base/nutin.config.js.hbs +1 -1
  39. package/templates/base/src/app/components/.gitkeep +0 -0
  40. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  41. package/templates/base/src/app/guards.ts.hbs +1 -3
  42. package/templates/base/src/app/main.ts.hbs +2 -9
  43. package/templates/base/src/app/routes.ts.hbs +9 -3
  44. package/templates/base/src/app/services/.gitkeep +0 -0
  45. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  46. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  47. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  48. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  49. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  50. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  51. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  52. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
  53. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  54. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
  55. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
  56. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  57. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
  58. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  59. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  60. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  61. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  62. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  63. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  64. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  65. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  66. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  67. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  68. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  69. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  70. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  71. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  72. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  73. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  74. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  75. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  76. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  77. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  78. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  79. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  80. package/templates/base/src/core/index.ts.hbs +4 -2
  81. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  82. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  83. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  84. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  85. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  86. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  87. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  88. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  89. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  90. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  91. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  92. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  93. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  94. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  95. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  96. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  97. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  98. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  99. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
  100. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
  101. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  102. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  103. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  104. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  105. package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
  106. package/templates/base/src/core/services/router/router.ts.hbs +122 -54
  107. package/templates/base/src/index.html.hbs +1 -0
  108. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  109. package/templates/base/src/styles/main.scss.hbs +1 -1
  110. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  111. package/templates/base/tools/builder/builder.js.hbs +26 -8
  112. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  113. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  114. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  115. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  116. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  117. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  118. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  119. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  120. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  121. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  122. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  123. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  124. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  125. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  126. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  127. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  128. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  129. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  130. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  131. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  132. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  133. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  134. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  135. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
  136. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  137. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  138. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  139. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  140. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
  141. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  142. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  143. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
  144. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  145. package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
  146. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  147. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  148. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  149. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  150. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  151. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  152. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
  153. package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
  154. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  155. package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
  156. package/templates/base/tools/dev/serve.js.hbs +152 -22
  157. package/templates/base/tools/dev/watcher.js.hbs +75 -28
  158. package/templates/base/tools/generator/generator.js.hbs +23 -18
  159. package/templates/base/tools/generator/handle-file.js.hbs +4 -24
  160. package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
  161. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  162. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  163. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  164. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  165. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  166. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  167. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  168. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  169. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  170. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  171. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  172. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  173. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  174. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  175. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  176. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  177. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  178. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  179. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  180. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  181. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  182. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  183. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  184. package/templates/base/tools/utils/build-lock.js.hbs +97 -0
  185. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  186. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  187. package/templates/base/tools/utils/format.js.hbs +1 -1
  188. package/templates/base/tools/utils/index.js.hbs +3 -1
  189. package/templates/base/tools/utils/print.js.hbs +2 -2
  190. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  191. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  192. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  193. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  194. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  195. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
  196. package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
  197. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  198. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  199. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  200. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  201. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  202. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  203. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  204. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  205. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
  206. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  207. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  208. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  209. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  210. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  211. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  212. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  213. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  214. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  215. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  216. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  220. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  221. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  222. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  223. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  224. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  225. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  226. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  227. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  228. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  229. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  230. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  231. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  232. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  233. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  234. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  235. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  236. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  237. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  238. package/templates/base/src/app/components/index.ts.hbs +0 -1
  239. package/templates/base/src/app/services/index.ts.hbs +0 -1
  240. package/templates/base/src/app/views/index.ts.hbs +0 -3
  241. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,35 +1,49 @@
1
- import { HttpBuilder } from '#root/dist/src/core/services/http-client/helpers/http-builder.helper.js';
2
-
3
- const mockAbortSignal = {
4
- aborted: false,
5
- onabort: null,
6
- addEventListener: () => {},
7
- removeEventListener: () => {},
8
- dispatchEvent: () => true,
9
- };
1
+ import * as HttpBuilder from '#root/dist/src/core/services/http-client/helpers/http-builder.helper.js';
2
+ import { path } from '#root/dist/src/core/services/http-client/helpers/http-builder.helper.js';
10
3
 
11
4
  describe('HttpBuilder', () => {
12
- // Tests for buildRequestBody
13
- it('buildRequestBody returns undefined for null input', () => {
14
- const result = HttpBuilder.buildRequestBody(null);
15
- expect(result).toBeUndefined();
16
- });
5
+ // The request body, as buildRequestOptions() builds it
6
+ const bodyOf = (data) => HttpBuilder.buildRequestOptions('POST', data, {}, new Headers(), new AbortController().signal).body;
17
7
 
18
- it('buildRequestBody returns undefined for undefined input', () => {
19
- const result = HttpBuilder.buildRequestBody(undefined);
20
- expect(result).toBeUndefined();
8
+ it('sends no body for null or undefined data', () => {
9
+ expect(bodyOf(null)).toBeUndefined();
10
+ expect(bodyOf(undefined)).toBeUndefined();
21
11
  });
22
12
 
23
- it('buildRequestBody returns string for object input', () => {
13
+ it('JSON-encodes an object body', () => {
24
14
  const data = { name: 'Alice' };
25
- const result = HttpBuilder.buildRequestBody(data);
26
- expect(result).toBe(JSON.stringify(data));
15
+ expect(bodyOf(data)).toBe(JSON.stringify(data));
16
+ });
17
+
18
+ it('JSON-encodes primitives, falsy ones included', () => {
19
+ expect(bodyOf(42)).toBe('42');
20
+ expect(bodyOf('text')).toBe('"text"');
21
+ expect(bodyOf(0)).toBe('0');
22
+ expect(bodyOf(false)).toBe('false');
23
+ expect(bodyOf('')).toBe('""');
24
+ });
25
+
26
+ it('passes fetch body types through unchanged', () => {
27
+ const bodies = [new FormData(), new Blob(['x']), new URLSearchParams('a=1'), new ArrayBuffer(1), new Uint8Array(1), new ReadableStream()];
28
+ bodies.forEach((body) => expect(bodyOf(body)).toBe(body));
27
29
  });
28
30
 
29
- it('buildRequestBody handles primitive types', () => {
30
- expect(HttpBuilder.buildRequestBody(42)).toBe('42');
31
- expect(HttpBuilder.buildRequestBody('text')).toContain('text');
32
- expect(HttpBuilder.buildRequestBody(true)).toBeDefined();
31
+ it('appendQueryParams skips null/undefined, stringifies values and repeats arrays', () => {
32
+ const url = new URL('https://api.example.com');
33
+ HttpBuilder.appendQueryParams(url, { a: 1, b: false, c: null, d: undefined, e: ['x', null, 2] });
34
+ expect(url.search).toBe('?a=1&b=false&e=x&e=2');
35
+ });
36
+
37
+ it('appendQueryParams does nothing without params', () => {
38
+ const url = new URL('https://api.example.com/?a=1');
39
+ HttpBuilder.appendQueryParams(url, undefined);
40
+ expect(url.search).toBe('?a=1');
41
+ });
42
+
43
+ it('mergeHeaders merges case-insensitively, later records winning, and skips undefined', () => {
44
+ const headers = HttpBuilder.mergeHeaders({ 'Content-Type': 'a', 'X-One': '1' }, undefined, { 'content-type': 'b' });
45
+ expect(headers.get('content-type')).toBe('b');
46
+ expect(headers.get('x-one')).toBe('1');
33
47
  });
34
48
 
35
49
  // Tests for appendQueryParams
@@ -60,6 +74,65 @@ describe('HttpBuilder', () => {
60
74
  });
61
75
 
62
76
  // Tests for buildRequestUrl
77
+ it('resolveUrl keeps an absolute endpoint when there is no base URL', () => {
78
+ expect(HttpBuilder.resolveUrl('', 'https://api.example.com/users').href).toBe('https://api.example.com/users');
79
+ });
80
+
81
+ it('resolveUrl resolves a relative endpoint against the page origin when there is no base URL', () => {
82
+ expect(HttpBuilder.resolveUrl('', '/api/users').href).toBe('http://localhost/api/users');
83
+ expect(HttpBuilder.resolveUrl('', 'api/users?x=1').href).toBe('http://localhost/api/users?x=1');
84
+ });
85
+
86
+ it('resolveUrl joins endpoints under the base URL with or without slashes', () => {
87
+ expect(HttpBuilder.resolveUrl('https://api.example.com', '/users/1').href).toBe('https://api.example.com/users/1');
88
+ expect(HttpBuilder.resolveUrl('https://api.example.com/v1', '/users').href).toBe('https://api.example.com/v1/users');
89
+ expect(HttpBuilder.resolveUrl('https://api.example.com/v1/', 'users').href).toBe('https://api.example.com/v1/users');
90
+ expect(HttpBuilder.resolveUrl('https://api.example.com/v1', '').href).toBe('https://api.example.com/v1/');
91
+ });
92
+
93
+ it('resolveUrl keeps host-like endpoints on the base origin', () => {
94
+ expect(HttpBuilder.resolveUrl('https://api.example.com', '@other.example/x').href).toBe('https://api.example.com/@other.example/x');
95
+ expect(HttpBuilder.resolveUrl('https://api.example.com', '.other.example/x').href).toBe('https://api.example.com/.other.example/x');
96
+ expect(HttpBuilder.resolveUrl('https://api.example.com', '//other.example/x').href).toBe('https://api.example.com/other.example/x');
97
+ });
98
+
99
+ it('resolveUrl keeps the base URL query ahead of the endpoint query', () => {
100
+ expect(HttpBuilder.resolveUrl('https://api.example.com/v1?v=2', '/users?page=3').href).toBe('https://api.example.com/v1/users?v=2&page=3');
101
+ });
102
+
103
+ it('resolveUrl only allows http and https', () => {
104
+ expect(HttpBuilder.resolveUrl('', 'http://api.example.com/a').protocol).toBe('http:');
105
+ expect(() => HttpBuilder.resolveUrl('', 'ftp://files.example.com/a')).toThrow('must use http or https');
106
+ });
107
+
108
+ it('resolveUrl rejects an encoded slash or backslash in the path unless the URL is under a trusted API', () => {
109
+ expect(() => HttpBuilder.resolveUrl('https://api.example.com', '/files/a%2Fb')).toThrow('trustedAPIs');
110
+ expect(() => HttpBuilder.resolveUrl('https://api.example.com', '/files/a%5cb')).toThrow('trustedAPIs');
111
+ const trusted = ['https://api.example.com/files/'];
112
+ expect(HttpBuilder.resolveUrl('https://api.example.com', '/files/a%2Fb', trusted).pathname).toBe('/files/a%2Fb');
113
+ expect(() => HttpBuilder.resolveUrl('https://api.example.com', '/other/a%2Fb', trusted)).toThrow('trustedAPIs');
114
+ expect(() => HttpBuilder.resolveUrl('', 'https://cdn.example.com/files/a%2Fb', trusted)).toThrow('trustedAPIs');
115
+ });
116
+
117
+ it('resolveUrl matches trusted APIs on whole path segments', () => {
118
+ const trusted = ['https://api.example.com/v1'];
119
+ expect(HttpBuilder.resolveUrl('', 'https://api.example.com/v1/a%2Fb', trusted).pathname).toBe('/v1/a%2Fb');
120
+ expect(() => HttpBuilder.resolveUrl('', 'https://api.example.com/v10/a%2Fb', trusted)).toThrow('trustedAPIs');
121
+ });
122
+
123
+ it('describe shows origin and path only', () => {
124
+ expect(HttpBuilder.describe(new URL('https://api.example.com/a?key=secret#h'))).toBe('https://api.example.com/a');
125
+ });
126
+
127
+ it('resolveUrl rejects absolute URLs on another origin', () => {
128
+ expect(() => HttpBuilder.resolveUrl('https://api.example.com', 'https://other.example/x')).toThrow();
129
+ });
130
+
131
+ it('resolveUrl rejects endpoints that leave the base path', () => {
132
+ expect(() => HttpBuilder.resolveUrl('https://api.example.com/v1', '/users/../../admin')).toThrow();
133
+ expect(() => HttpBuilder.resolveUrl('https://api.example.com/v1', '%2e%2e/admin')).toThrow();
134
+ });
135
+
63
136
  it('buildRequestUrl returns URL without query params', () => {
64
137
  const url = HttpBuilder.buildRequestUrl('https://api.example.com');
65
138
  expect(url.search).toBe('');
@@ -81,54 +154,64 @@ describe('HttpBuilder', () => {
81
154
  });
82
155
 
83
156
  // Tests for buildRequestOptions
84
- it('buildRequestOptions sets GET without body', () => {
85
- const options = HttpBuilder.buildRequestOptions(
86
- 'GET',
87
- null,
88
- { headers: { 'X-Test': 'value' } },
89
- mockAbortSignal
90
- );
157
+ it('buildRequestOptions sets GET without body or content type', () => {
158
+ const headers = new Headers({ 'X-Test': 'value' });
159
+ const signal = new AbortController().signal;
160
+ const options = HttpBuilder.buildRequestOptions('GET', null, {}, headers, signal);
91
161
  expect(options.method).toBe('GET');
92
162
  expect(options.body).toBeUndefined();
93
- expect(JSON.stringify(options.headers)).toBe(JSON.stringify({ 'X-Test': 'value' }));
94
- expect(options.signal).toBe(mockAbortSignal);
163
+ expect(options.headers.get('x-test')).toBe('value');
164
+ expect(options.headers.has('content-type')).toBe(false);
165
+ expect(options.signal).toBe(signal);
95
166
  });
96
167
 
97
- it('buildRequestOptions sets POST with body', () => {
98
- const data = { name: 'Alice' };
168
+ it('buildRequestOptions sets a JSON body and content type', () => {
169
+ const options = HttpBuilder.buildRequestOptions('POST', { name: 'Alice' }, {}, new Headers(), new AbortController().signal);
170
+ expect(options.body).toBe('{"name":"Alice"}');
171
+ expect(options.headers.get('content-type')).toBe('application/json');
172
+ });
173
+
174
+ it('buildRequestOptions keeps an explicit content type and passes fetch options through', () => {
99
175
  const options = HttpBuilder.buildRequestOptions(
100
176
  'POST',
101
- data,
102
- { headers: { 'Content-Type': 'application/json' } },
103
- mockAbortSignal
177
+ { a: 1 },
178
+ { credentials: 'include', cache: 'no-store', referrerPolicy: 'no-referrer', redirect: 'manual' },
179
+ new Headers({ 'content-type': 'text/plain' }),
180
+ new AbortController().signal
104
181
  );
105
- expect(options.method).toBe('POST');
106
- expect(options.body).toBe(JSON.stringify(data));
107
- });
108
-
109
- it('buildRequestOptions handles empty config', () => {
110
- const options = HttpBuilder.buildRequestOptions('GET', null, {}, mockAbortSignal);
111
- expect(options.body).toBeUndefined();
112
- expect(options.headers).toBeUndefined();
182
+ expect(options.headers.get('content-type')).toBe('text/plain');
183
+ expect(options.credentials).toBe('include');
184
+ expect(options.cache).toBe('no-store');
185
+ expect(options.referrerPolicy).toBe('no-referrer');
186
+ expect(options.redirect).toBe('manual');
113
187
  });
114
188
 
115
189
  it('buildRequestOptions sets PUT with body', () => {
116
190
  const data = { id: 1, name: 'Updated' };
117
- const options = HttpBuilder.buildRequestOptions('PUT', data, {}, mockAbortSignal);
191
+ const options = HttpBuilder.buildRequestOptions('PUT', data, {}, new Headers(), new AbortController().signal);
118
192
  expect(options.method).toBe('PUT');
119
193
  expect(options.body).toBe(JSON.stringify(data));
120
194
  });
121
195
 
122
196
  it('buildRequestOptions sets PATCH with body', () => {
123
197
  const data = { name: 'Patched' };
124
- const options = HttpBuilder.buildRequestOptions('PATCH', data, {}, mockAbortSignal);
198
+ const options = HttpBuilder.buildRequestOptions('PATCH', data, {}, new Headers(), new AbortController().signal);
125
199
  expect(options.method).toBe('PATCH');
126
200
  expect(options.body).toBe(JSON.stringify(data));
127
201
  });
128
202
 
129
203
  it('buildRequestOptions sets DELETE without body', () => {
130
- const options = HttpBuilder.buildRequestOptions('DELETE', undefined, {}, mockAbortSignal);
204
+ const options = HttpBuilder.buildRequestOptions('DELETE', undefined, {}, new Headers(), new AbortController().signal);
131
205
  expect(options.method).toBe('DELETE');
132
206
  expect(options.body).toBeUndefined();
133
207
  });
134
- })
208
+
209
+ it('path encodes values and keeps the static parts', () => {
210
+ expect(path`/users/${'a b'}/files/${'x/y'}?v=${1}`).toBe('/users/a%20b/files/x%2Fy?v=1');
211
+ });
212
+
213
+ it('path rejects "." and ".." values', () => {
214
+ expect(() => path`/a/${'.'}`).toThrow('not a valid path value');
215
+ expect(() => path`/a/${'..'}`).toThrow('not a valid path value');
216
+ });
217
+ });
@@ -1,48 +1,156 @@
1
- export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
2
-
3
- /**
4
- * headers?: Record<string, string>;
5
- * queryParams?: Record<string, string>;
6
- * timeout?: number;
7
- */
8
- export interface IRequestConfig {
9
- headers?: Record<string, string>;
10
- queryParams?: Record<string, string>;
11
- timeout?: number;
12
- }
13
-
14
- export class HttpBuilder {
15
- public static buildRequestOptions(
16
- method: HttpMethod,
17
- data: unknown,
18
- config: IRequestConfig,
19
- signal: AbortSignal
20
- ): RequestInit {
21
- return {
22
- method,
23
- headers: config.headers,
24
- body: this.buildRequestBody(data),
25
- signal
26
- };
1
+ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
2
+ const ENCODED_SEPARATOR = /%2f|%5c/i;
3
+
4
+ export function buildRequestOptions(
5
+ method: Nutin.HttpMethod,
6
+ data: unknown,
7
+ config: Nutin.RequestConfig,
8
+ headers: Headers,
9
+ signal: AbortSignal
10
+ ): RequestInit {
11
+ const body = buildRequestBody(data);
12
+ if (isJsonBody(data) && !headers.has('content-type')) {
13
+ headers.set('content-type', 'application/json');
27
14
  }
28
15
 
29
- public static buildRequestUrl(endpoint: string, queryParams?: Record<string, string>): URL {
30
- const url = new URL(endpoint);
31
-
32
- if (queryParams) {
33
- this.appendQueryParams(url, queryParams);
34
- }
16
+ const options: RequestInit = { method, headers, body, signal };
17
+ if (config.credentials) options.credentials = config.credentials;
18
+ if (config.cache) options.cache = config.cache;
19
+ if (config.referrerPolicy) options.referrerPolicy = config.referrerPolicy;
20
+ if (config.redirect) options.redirect = config.redirect;
21
+ return options;
22
+ }
35
23
 
36
- return url;
37
- }
24
+ // Merges header records case-insensitively: later records win ("content-type" overrides "Content-Type").
25
+ export function mergeHeaders(...records: Array<Record<string, string> | undefined>): Headers {
26
+ const headers = new Headers();
27
+ records.forEach((record) => {
28
+ if (record) new Headers(record).forEach((value, name) => headers.set(name, value));
29
+ });
30
+ return headers;
31
+ }
32
+
33
+ // Resolves an endpoint to the URL that will be fetched.
34
+ // - With a base URL, the endpoint is joined onto it as a URL (never as a string), and the
35
+ // result must stay on the base origin and under the base path; the base's query is kept.
36
+ // - Without one, a relative endpoint resolves against the page's origin (not document.baseURI).
37
+ // Only http(s) is allowed, and an encoded "/" or "\" in the path is rejected (servers may decode
38
+ // it into a separator) unless the URL is under one of `trustedAPIs`.
39
+ export function resolveUrl(baseUrl: string, endpoint: string, trustedAPIs: string[] = []): URL {
40
+ const url = baseUrl ? resolveAgainstBase(baseUrl, endpoint) : new URL(endpoint, window.location.origin);
38
41
 
39
- private static buildRequestBody(data: unknown): string | undefined {
40
- return data ? JSON.stringify(data) : undefined;
42
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
43
+ throw new Error(`Request URL must use http or https, got "${url.protocol}".`);
41
44
  }
45
+ if (ENCODED_SEPARATOR.test(url.pathname) && !isTrusted(url, trustedAPIs)) {
46
+ throw new Error(
47
+ `Request URL "${describe(url)}" contains an encoded "/" or "\\". ` +
48
+ 'List its API in the HttpClient "trustedAPIs" option if it expects them.'
49
+ );
50
+ }
51
+ return url;
52
+ }
53
+
54
+ export function buildRequestUrl(endpoint: string, queryParams?: Nutin.RequestConfig['queryParams']): URL {
55
+ const url = new URL(endpoint);
56
+ appendQueryParams(url, queryParams);
57
+ return url;
58
+ }
42
59
 
43
- private static appendQueryParams(url: URL, queryParams: Record<string, string>): void {
44
- Object.entries(queryParams).forEach(([key, value]) => {
45
- url.searchParams.append(key, value);
60
+ export function appendQueryParams(url: URL, queryParams?: Nutin.RequestConfig['queryParams']): void {
61
+ if (!queryParams) return;
62
+ Object.entries(queryParams).forEach(([key, value]) => {
63
+ const values = Array.isArray(value) ? value : [value];
64
+ values.forEach((item) => {
65
+ if (item !== null && item !== undefined) url.searchParams.append(key, String(item));
46
66
  });
67
+ });
68
+ }
69
+
70
+ // Origin + path only: query strings often carry keys or tokens.
71
+ export function describe(url: URL): string {
72
+ return `${url.origin}${url.pathname}`;
73
+ }
74
+
75
+ // A followed redirect must end where the request itself was allowed to go: under the base
76
+ // URL, or (without one) on the requested origin. Otherwise the response is another origin's,
77
+ // and custom headers (unlike Authorization) were forwarded to it.
78
+ export function assertRedirectTarget(baseUrl: string, requested: URL, finalUrl: string): void {
79
+ const final = new URL(finalUrl);
80
+ const allowed = baseUrl ? isUnderBase(final, baseUrl) : final.origin === requested.origin;
81
+ if (!allowed) {
82
+ throw new Error(
83
+ `Request to "${describe(requested)}" was redirected to "${describe(final)}", outside ` +
84
+ `${baseUrl ? `the HttpClient base URL "${describe(new URL(baseUrl))}"` : 'its origin'}.`
85
+ );
86
+ }
87
+ }
88
+
89
+ function isUnderBase(url: URL, baseUrl: string): boolean {
90
+ const base = new URL(baseUrl);
91
+ const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
92
+ const underBase = url.pathname.startsWith(basePath) || `${url.pathname}/` === basePath;
93
+ return url.origin === base.origin && underBase;
94
+ }
95
+
96
+ function resolveAgainstBase(baseUrl: string, endpoint: string): URL {
97
+ const base = new URL(baseUrl);
98
+ const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
99
+ const url = new URL(endpoint.replace(/^\/+/, ''), `${base.origin}${basePath}`);
100
+
101
+ if (!isUnderBase(url, baseUrl)) {
102
+ throw new Error(
103
+ `Request URL "${describe(url)}" is outside the HttpClient base URL "${describe(base)}".`
104
+ );
105
+ }
106
+
107
+ // Keep the base URL's own query (e.g. an API version), before the endpoint's.
108
+ if (base.search) {
109
+ const params = new URLSearchParams(base.search);
110
+ url.searchParams.forEach((value, key) => params.append(key, value));
111
+ url.search = params.toString();
112
+ }
113
+ return url;
114
+ }
115
+
116
+ // Same segment boundary as the base URL: a trusted ".../v1" doesn't cover ".../v10".
117
+ function isTrusted(url: URL, trustedAPIs: string[]): boolean {
118
+ return trustedAPIs.some((entry) => isUnderBase(url, entry));
119
+ }
120
+
121
+ // Only null/undefined mean "no body"; fetch's own body types are sent as they are.
122
+ function buildRequestBody(data: unknown): BodyInit | undefined {
123
+ if (data === null || data === undefined) return undefined;
124
+ if (isNativeBody(data)) return data as BodyInit;
125
+ return JSON.stringify(data);
126
+ }
127
+
128
+ function isJsonBody(data: unknown): boolean {
129
+ return data !== null && data !== undefined && !isNativeBody(data);
130
+ }
131
+
132
+ function isNativeBody(data: unknown): boolean {
133
+ return (
134
+ (typeof FormData !== 'undefined' && data instanceof FormData) ||
135
+ (typeof Blob !== 'undefined' && data instanceof Blob) ||
136
+ (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) ||
137
+ (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) ||
138
+ data instanceof ArrayBuffer ||
139
+ ArrayBuffer.isView(data)
140
+ );
141
+ }
142
+
143
+ // Builds an endpoint path from values: each value is URL-encoded, so it stays one path
144
+ // segment ("a/b" becomes "a%2Fb", which resolveUrl only accepts under `trustedAPIs`).
145
+ // "." and ".." are rejected: as a whole segment they would move up the path.
146
+ export function path(strings: TemplateStringsArray, ...values: unknown[]): string {
147
+ return strings.reduce((out, str, i) => out + encodePathValue(values[i - 1]) + str);
148
+ }
149
+
150
+ function encodePathValue(value: unknown): string {
151
+ const text = String(value);
152
+ if (text === '.' || text === '..') {
153
+ throw new Error(`path\`\`: "${text}" is not a valid path value.`);
47
154
  }
155
+ return encodeURIComponent(text);
48
156
  }
@@ -1,4 +1,5 @@
1
- import { HttpManager, HttpError } from '#root/dist/src/core/services/http-client/helpers/http-manager.helper.js';
1
+ import * as HttpManager from '#root/dist/src/core/services/http-client/helpers/http-manager.helper.js';
2
+ import { HttpError } from '#root/dist/src/core/services/http-client/helpers/http-manager.helper.js';
2
3
 
3
4
  describe('HttpManager', () => {
4
5
  it('should create an AbortController with a timeout', () => {
@@ -9,25 +10,69 @@ describe('HttpManager', () => {
9
10
  });
10
11
 
11
12
  it('should create an AbortController without a timeout', () => {
12
- const { controller, timeoutId } = HttpManager.createAbortController();
13
+ const { controller, timeoutId, timedOut } = HttpManager.createAbortController();
13
14
  expect(controller).toBeInstanceOf(AbortController);
14
15
  expect(timeoutId).toBe(null);
16
+ expect(timedOut()).toBe(false);
15
17
  });
16
18
 
17
- it('should parse a valid JSON error response', async () => {
19
+ it('should mark the request as timed out when its timeout fires', () => {
20
+ useFakeTimers();
21
+ try {
22
+ const { controller, timedOut, cleanup } = HttpManager.createAbortController(5);
23
+ advanceTimersByTime(4);
24
+ expect(timedOut()).toBe(false);
25
+ advanceTimersByTime(1);
26
+ expect(controller.signal.aborted).toBe(true);
27
+ expect(timedOut()).toBe(true);
28
+ cleanup();
29
+ } finally {
30
+ useRealTimers();
31
+ }
32
+ });
33
+
34
+ it('should reject a timeout that is not a positive number', () => {
35
+ expect(() => HttpManager.createAbortController(0)).toThrow('positive number');
36
+ expect(() => HttpManager.createAbortController(-5)).toThrow('positive number');
37
+ });
38
+
39
+ it('should forward an abort from the caller signal, and stop forwarding after cleanup', () => {
40
+ const caller = new AbortController();
41
+ const first = HttpManager.createAbortController(undefined, caller.signal);
42
+ caller.abort();
43
+ expect(first.controller.signal.aborted).toBe(true);
44
+ expect(first.timedOut()).toBe(false);
45
+
46
+ const other = new AbortController();
47
+ const second = HttpManager.createAbortController(undefined, other.signal);
48
+ second.cleanup();
49
+ other.abort();
50
+ expect(second.controller.signal.aborted).toBe(false);
51
+ });
52
+
53
+ it('should start aborted when the caller signal already is', () => {
54
+ const caller = new AbortController();
55
+ caller.abort();
56
+ expect(HttpManager.createAbortController(undefined, caller.signal).controller.signal.aborted).toBe(true);
57
+ });
58
+
59
+ it('parses a JSON error response into the HttpError', async () => {
18
60
  const mockResponse = new Response(JSON.stringify({ error: 'Invalid' }), {
61
+ status: 422,
19
62
  headers: { 'Content-Type': 'application/json' }
20
63
  });
21
- const result = await HttpManager.safeParseErrorResponse(mockResponse);
22
- expect(JSON.stringify(result)).toBe(JSON.stringify({ error: 'Invalid' }));
64
+ const error = await HttpManager.validateResponse(mockResponse).catch((e) => e);
65
+ expect(JSON.stringify(error.response)).toBe(JSON.stringify({ error: 'Invalid' }));
23
66
  });
24
67
 
25
- it('should return null for non-JSON error response', async () => {
68
+ it('gives the HttpError a null response for a non-JSON error body', async () => {
26
69
  const mockResponse = new Response('Not JSON', {
70
+ status: 500,
27
71
  headers: { 'Content-Type': 'text/plain' }
28
72
  });
29
- const result = await HttpManager.safeParseErrorResponse(mockResponse);
30
- expect(result).toBe(null);
73
+ const error = await HttpManager.validateResponse(mockResponse).catch((e) => e);
74
+ expect(error.status).toBe(500);
75
+ expect(error.response).toBe(null);
31
76
  });
32
77
 
33
78
  it('should not throw for ok responses in validateResponse', async () => {
@@ -75,11 +120,14 @@ describe('HttpManager', () => {
75
120
  expect(thrown).toBe(true);
76
121
  });
77
122
 
78
- it('should detect JSON content types', () => {
79
- expect(HttpManager.isJsonResponse('application/json')).toBe(true);
80
- expect(HttpManager.isJsonResponse('application/json; charset=utf-8')).toBe(true);
81
- expect(HttpManager.isJsonResponse('text/html')).toBe(false);
82
- expect(HttpManager.isJsonResponse(null)).toBe(false);
123
+ it('parses JSON and +json content types, and returns anything else as text', async () => {
124
+ const parse = (contentType) => HttpManager.parseSuccessResponse(new Response('{"a":1}', contentType ? { headers: { 'Content-Type': contentType } } : {}));
125
+ for (const type of ['application/json', 'application/json; charset=utf-8', 'Application/JSON', 'application/problem+json']) {
126
+ expect(await parse(type)).toEqual({ a: 1 });
127
+ }
128
+ for (const type of ['application/jsonp', 'text/html', null]) {
129
+ expect(await parse(type)).toBe('{"a":1}');
130
+ }
83
131
  });
84
132
 
85
133
  it('should parse success JSON response', async () => {
@@ -90,6 +138,13 @@ describe('HttpManager', () => {
90
138
  expect(JSON.stringify(result)).toBe(JSON.stringify({ message: 'ok' }));
91
139
  });
92
140
 
141
+ it('should resolve 204, 205 and empty bodies to undefined', async () => {
142
+ expect(await HttpManager.parseSuccessResponse(new Response(null, { status: 204 }))).toBe(undefined);
143
+ expect(await HttpManager.parseSuccessResponse(new Response(null, { status: 205 }))).toBe(undefined);
144
+ const empty = new Response('', { headers: { 'Content-Type': 'application/json' } });
145
+ expect(await HttpManager.parseSuccessResponse(empty)).toBe(undefined);
146
+ });
147
+
93
148
  it('should parse success text response as fallback', async () => {
94
149
  const mockResponse = new Response('plain text', {
95
150
  headers: { 'Content-Type': 'text/plain' }
@@ -98,19 +153,20 @@ describe('HttpManager', () => {
98
153
  expect(result).toBe('plain text');
99
154
  });
100
155
 
101
- it('should detect timeout errors', () => {
102
- const err = new DOMException('Aborted', 'AbortError');
103
- expect(HttpManager.isTimeoutError(err)).toBe(true);
156
+ it('reports only an AbortError as a timeout', () => {
157
+ expect(() => HttpManager.handleRequestError(new DOMException('Aborted', 'AbortError'), true)).toThrow('Request timed out');
158
+ expect(() => HttpManager.handleRequestError(new Error('Other'), true)).toThrow('Other');
159
+ });
104
160
 
105
- const otherErr = new Error('Other');
106
- expect(HttpManager.isTimeoutError(otherErr)).toBe(false);
161
+ it('should rethrow an abort that was not the timeout as it is', () => {
162
+ expect(() => HttpManager.handleRequestError(new DOMException('Aborted', 'AbortError'))).toThrow('Aborted');
107
163
  });
108
164
 
109
165
  it('should throw timeout error from handleRequestError', () => {
110
166
  const err = new DOMException('Aborted', 'AbortError');
111
167
  let thrown = false;
112
168
  try {
113
- HttpManager.handleRequestError(err);
169
+ HttpManager.handleRequestError(err, true);
114
170
  } catch (e) {
115
171
  thrown = true;
116
172
  expect(e.message).toBe('Request timed out');