@fgv/ts-web-extras 5.1.0-3 → 5.1.0-30

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 (275) hide show
  1. package/dist/packlets/crypto-utils/browserCryptoProvider.js +330 -18
  2. package/dist/packlets/crypto-utils/browserCryptoProvider.js.map +1 -1
  3. package/dist/packlets/crypto-utils/index.js +12 -0
  4. package/dist/packlets/crypto-utils/index.js.map +1 -1
  5. package/dist/packlets/file-tree/directoryHandleStore.js.map +1 -1
  6. package/dist/packlets/file-tree/fileApiTreeAccessors.js +1 -1
  7. package/dist/packlets/file-tree/fileApiTreeAccessors.js.map +1 -1
  8. package/dist/packlets/file-tree/fileSystemAccessTreeAccessors.js +2 -2
  9. package/dist/packlets/file-tree/fileSystemAccessTreeAccessors.js.map +1 -1
  10. package/dist/packlets/file-tree/httpTreeAccessors.js +74 -44
  11. package/dist/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  12. package/dist/packlets/file-tree/localStorageTreeAccessors.js.map +1 -1
  13. package/dist/packlets/helpers/fileTreeHelpers.js +1 -1
  14. package/dist/packlets/helpers/fileTreeHelpers.js.map +1 -1
  15. package/dist/ts-web-extras.d.ts +141 -10
  16. package/dist/tsdoc-metadata.json +1 -1
  17. package/eslint.config.js +32 -0
  18. package/lib/packlets/crypto-utils/browserCryptoProvider.d.ts +115 -6
  19. package/lib/packlets/crypto-utils/browserCryptoProvider.d.ts.map +1 -1
  20. package/lib/packlets/crypto-utils/browserCryptoProvider.js +329 -17
  21. package/lib/packlets/crypto-utils/browserCryptoProvider.js.map +1 -1
  22. package/lib/packlets/crypto-utils/index.d.ts +13 -0
  23. package/lib/packlets/crypto-utils/index.d.ts.map +1 -1
  24. package/lib/packlets/crypto-utils/index.js +13 -0
  25. package/lib/packlets/crypto-utils/index.js.map +1 -1
  26. package/lib/packlets/file-tree/directoryHandleStore.d.ts +2 -2
  27. package/lib/packlets/file-tree/directoryHandleStore.d.ts.map +1 -1
  28. package/lib/packlets/file-tree/directoryHandleStore.js.map +1 -1
  29. package/lib/packlets/file-tree/fileApiTreeAccessors.d.ts +2 -2
  30. package/lib/packlets/file-tree/fileApiTreeAccessors.d.ts.map +1 -1
  31. package/lib/packlets/file-tree/fileApiTreeAccessors.js +1 -1
  32. package/lib/packlets/file-tree/fileApiTreeAccessors.js.map +1 -1
  33. package/lib/packlets/file-tree/fileSystemAccessTreeAccessors.d.ts.map +1 -1
  34. package/lib/packlets/file-tree/fileSystemAccessTreeAccessors.js +2 -2
  35. package/lib/packlets/file-tree/fileSystemAccessTreeAccessors.js.map +1 -1
  36. package/lib/packlets/file-tree/httpTreeAccessors.d.ts +6 -0
  37. package/lib/packlets/file-tree/httpTreeAccessors.d.ts.map +1 -1
  38. package/lib/packlets/file-tree/httpTreeAccessors.js +74 -44
  39. package/lib/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  40. package/lib/packlets/file-tree/localStorageTreeAccessors.js.map +1 -1
  41. package/lib/packlets/helpers/fileTreeHelpers.d.ts +1 -1
  42. package/lib/packlets/helpers/fileTreeHelpers.d.ts.map +1 -1
  43. package/lib/packlets/helpers/fileTreeHelpers.js +6 -6
  44. package/lib/packlets/helpers/fileTreeHelpers.js.map +1 -1
  45. package/package.json +16 -15
  46. package/.rush/temp/61c1d4a91d98f048b475a5bb3e6541c5d27819de.tar.log +0 -237
  47. package/.rush/temp/chunked-rush-logs/ts-web-extras.build.chunks.jsonl +0 -55
  48. package/.rush/temp/operation/build/all.log +0 -55
  49. package/.rush/temp/operation/build/log-chunks.jsonl +0 -55
  50. package/.rush/temp/operation/build/state.json +0 -3
  51. package/.rush/temp/shrinkwrap-deps.json +0 -635
  52. package/CHANGELOG.md +0 -23
  53. package/config/api-extractor.json +0 -343
  54. package/config/jest.config.json +0 -19
  55. package/config/rig.json +0 -16
  56. package/config/typedoc.json +0 -6
  57. package/dist/test/mocks/idb-keyval.js +0 -6
  58. package/dist/test/mocks/idb-keyval.js.map +0 -1
  59. package/dist/test/setupTests.js +0 -74
  60. package/dist/test/setupTests.js.map +0 -1
  61. package/dist/test/unit/browserHashProvider.test.js +0 -140
  62. package/dist/test/unit/browserHashProvider.test.js.map +0 -1
  63. package/dist/test/unit/directoryHandleStore.test.js +0 -190
  64. package/dist/test/unit/directoryHandleStore.test.js.map +0 -1
  65. package/dist/test/unit/fileApiTreeAccessors.test.js +0 -1188
  66. package/dist/test/unit/fileApiTreeAccessors.test.js.map +0 -1
  67. package/dist/test/unit/fileApiTypes.test.js +0 -472
  68. package/dist/test/unit/fileApiTypes.test.js.map +0 -1
  69. package/dist/test/unit/fileSystemAccessTreeAccessors.test.js +0 -622
  70. package/dist/test/unit/fileSystemAccessTreeAccessors.test.js.map +0 -1
  71. package/dist/test/unit/fileTreeHelpers.test.js +0 -590
  72. package/dist/test/unit/fileTreeHelpers.test.js.map +0 -1
  73. package/dist/test/unit/httpTreeAccessors.test.js +0 -1229
  74. package/dist/test/unit/httpTreeAccessors.test.js.map +0 -1
  75. package/dist/test/unit/localStorageTreeAccessors.test.js +0 -812
  76. package/dist/test/unit/localStorageTreeAccessors.test.js.map +0 -1
  77. package/dist/test/unit/urlParams.test.js +0 -393
  78. package/dist/test/unit/urlParams.test.js.map +0 -1
  79. package/dist/test/utils/fileSystemAccessMocks.js +0 -271
  80. package/dist/test/utils/fileSystemAccessMocks.js.map +0 -1
  81. package/dist/test/utils/testHelpers.js +0 -124
  82. package/dist/test/utils/testHelpers.js.map +0 -1
  83. package/docs/@fgv/namespaces/CryptoUtils/README.md +0 -18
  84. package/docs/@fgv/namespaces/CryptoUtils/classes/BrowserCryptoProvider.md +0 -203
  85. package/docs/@fgv/namespaces/CryptoUtils/classes/BrowserHashProvider.md +0 -63
  86. package/docs/@fgv/namespaces/CryptoUtils/functions/createBrowserCryptoProvider.md +0 -18
  87. package/docs/@fgv/namespaces/FileTreeHelpers/README.md +0 -19
  88. package/docs/@fgv/namespaces/FileTreeHelpers/functions/extractFileListMetadata.md +0 -23
  89. package/docs/@fgv/namespaces/FileTreeHelpers/functions/extractFileMetadata.md +0 -23
  90. package/docs/@fgv/namespaces/FileTreeHelpers/functions/fromDirectoryUpload.md +0 -33
  91. package/docs/@fgv/namespaces/FileTreeHelpers/functions/fromFileList.md +0 -33
  92. package/docs/@fgv/namespaces/FileTreeHelpers/functions/getOriginalFile.md +0 -25
  93. package/docs/@fgv/namespaces/FileTreeHelpers/variables/defaultFileApiTreeInitParams.md +0 -11
  94. package/docs/README.md +0 -78
  95. package/docs/classes/DirectoryHandleStore.md +0 -116
  96. package/docs/classes/FileApiTreeAccessors.md +0 -286
  97. package/docs/classes/FileSystemAccessTreeAccessors.md +0 -557
  98. package/docs/classes/HttpTreeAccessors.md +0 -508
  99. package/docs/classes/LocalStorageTreeAccessors.md +0 -520
  100. package/docs/functions/exportAsJson.md +0 -23
  101. package/docs/functions/exportUsingFileSystemAPI.md +0 -26
  102. package/docs/functions/extractDirectoryPath.md +0 -23
  103. package/docs/functions/isDirectoryHandle.md +0 -23
  104. package/docs/functions/isFileHandle.md +0 -23
  105. package/docs/functions/isFilePath.md +0 -21
  106. package/docs/functions/parseContextFilter.md +0 -22
  107. package/docs/functions/parseQualifierDefaults.md +0 -22
  108. package/docs/functions/parseResourceTypes.md +0 -22
  109. package/docs/functions/parseUrlParameters.md +0 -15
  110. package/docs/functions/safeShowDirectoryPicker.md +0 -24
  111. package/docs/functions/safeShowOpenFilePicker.md +0 -24
  112. package/docs/functions/safeShowSaveFilePicker.md +0 -24
  113. package/docs/functions/supportsFileSystemAccess.md +0 -23
  114. package/docs/interfaces/FilePickerAcceptType.md +0 -16
  115. package/docs/interfaces/FileSystemCreateWritableOptions.md +0 -15
  116. package/docs/interfaces/FileSystemDirectoryHandle.md +0 -187
  117. package/docs/interfaces/FileSystemFileHandle.md +0 -106
  118. package/docs/interfaces/FileSystemGetDirectoryOptions.md +0 -15
  119. package/docs/interfaces/FileSystemGetFileOptions.md +0 -15
  120. package/docs/interfaces/FileSystemHandle.md +0 -69
  121. package/docs/interfaces/FileSystemHandlePermissionDescriptor.md +0 -15
  122. package/docs/interfaces/FileSystemRemoveOptions.md +0 -15
  123. package/docs/interfaces/FileSystemWritableFileStream.md +0 -127
  124. package/docs/interfaces/IDirectoryHandleTreeInitializer.md +0 -17
  125. package/docs/interfaces/IFileHandleTreeInitializer.md +0 -16
  126. package/docs/interfaces/IFileListTreeInitializer.md +0 -15
  127. package/docs/interfaces/IFileMetadata.md +0 -19
  128. package/docs/interfaces/IFileSystemAccessTreeParams.md +0 -30
  129. package/docs/interfaces/IFsAccessApis.md +0 -57
  130. package/docs/interfaces/IHttpTreeParams.md +0 -32
  131. package/docs/interfaces/ILocalStorageTreeParams.md +0 -30
  132. package/docs/interfaces/IUrlConfigOptions.md +0 -27
  133. package/docs/interfaces/ShowDirectoryPickerOptions.md +0 -17
  134. package/docs/interfaces/ShowOpenFilePickerOptions.md +0 -19
  135. package/docs/interfaces/ShowSaveFilePickerOptions.md +0 -19
  136. package/docs/type-aliases/TreeInitializer.md +0 -11
  137. package/docs/type-aliases/WellKnownDirectory.md +0 -11
  138. package/docs/type-aliases/WindowWithFsAccess.md +0 -11
  139. package/docs/variables/DEFAULT_DIRECTORY_HANDLE_DB.md +0 -11
  140. package/docs/variables/DEFAULT_DIRECTORY_HANDLE_STORE.md +0 -11
  141. package/etc/ts-web-extras.api.md +0 -433
  142. package/lib/test/mocks/idb-keyval.d.ts +0 -6
  143. package/lib/test/mocks/idb-keyval.d.ts.map +0 -1
  144. package/lib/test/mocks/idb-keyval.js +0 -9
  145. package/lib/test/mocks/idb-keyval.js.map +0 -1
  146. package/lib/test/setupTests.d.ts +0 -2
  147. package/lib/test/setupTests.d.ts.map +0 -1
  148. package/lib/test/setupTests.js +0 -76
  149. package/lib/test/setupTests.js.map +0 -1
  150. package/lib/test/unit/browserHashProvider.test.d.ts +0 -2
  151. package/lib/test/unit/browserHashProvider.test.d.ts.map +0 -1
  152. package/lib/test/unit/browserHashProvider.test.js +0 -142
  153. package/lib/test/unit/browserHashProvider.test.js.map +0 -1
  154. package/lib/test/unit/directoryHandleStore.test.d.ts +0 -2
  155. package/lib/test/unit/directoryHandleStore.test.d.ts.map +0 -1
  156. package/lib/test/unit/directoryHandleStore.test.js +0 -192
  157. package/lib/test/unit/directoryHandleStore.test.js.map +0 -1
  158. package/lib/test/unit/fileApiTreeAccessors.test.d.ts +0 -2
  159. package/lib/test/unit/fileApiTreeAccessors.test.d.ts.map +0 -1
  160. package/lib/test/unit/fileApiTreeAccessors.test.js +0 -1190
  161. package/lib/test/unit/fileApiTreeAccessors.test.js.map +0 -1
  162. package/lib/test/unit/fileApiTypes.test.d.ts +0 -2
  163. package/lib/test/unit/fileApiTypes.test.d.ts.map +0 -1
  164. package/lib/test/unit/fileApiTypes.test.js +0 -474
  165. package/lib/test/unit/fileApiTypes.test.js.map +0 -1
  166. package/lib/test/unit/fileSystemAccessTreeAccessors.test.d.ts +0 -2
  167. package/lib/test/unit/fileSystemAccessTreeAccessors.test.d.ts.map +0 -1
  168. package/lib/test/unit/fileSystemAccessTreeAccessors.test.js +0 -624
  169. package/lib/test/unit/fileSystemAccessTreeAccessors.test.js.map +0 -1
  170. package/lib/test/unit/fileTreeHelpers.test.d.ts +0 -2
  171. package/lib/test/unit/fileTreeHelpers.test.d.ts.map +0 -1
  172. package/lib/test/unit/fileTreeHelpers.test.js +0 -592
  173. package/lib/test/unit/fileTreeHelpers.test.js.map +0 -1
  174. package/lib/test/unit/httpTreeAccessors.test.d.ts +0 -2
  175. package/lib/test/unit/httpTreeAccessors.test.d.ts.map +0 -1
  176. package/lib/test/unit/httpTreeAccessors.test.js +0 -1231
  177. package/lib/test/unit/httpTreeAccessors.test.js.map +0 -1
  178. package/lib/test/unit/localStorageTreeAccessors.test.d.ts +0 -2
  179. package/lib/test/unit/localStorageTreeAccessors.test.d.ts.map +0 -1
  180. package/lib/test/unit/localStorageTreeAccessors.test.js +0 -814
  181. package/lib/test/unit/localStorageTreeAccessors.test.js.map +0 -1
  182. package/lib/test/unit/urlParams.test.d.ts +0 -2
  183. package/lib/test/unit/urlParams.test.d.ts.map +0 -1
  184. package/lib/test/unit/urlParams.test.js +0 -395
  185. package/lib/test/unit/urlParams.test.js.map +0 -1
  186. package/lib/test/utils/fileSystemAccessMocks.d.ts +0 -53
  187. package/lib/test/utils/fileSystemAccessMocks.d.ts.map +0 -1
  188. package/lib/test/utils/fileSystemAccessMocks.js +0 -277
  189. package/lib/test/utils/fileSystemAccessMocks.js.map +0 -1
  190. package/lib/test/utils/testHelpers.d.ts +0 -51
  191. package/lib/test/utils/testHelpers.d.ts.map +0 -1
  192. package/lib/test/utils/testHelpers.js +0 -133
  193. package/lib/test/utils/testHelpers.js.map +0 -1
  194. package/rush-logs/ts-web-extras.build.cache.log +0 -3
  195. package/rush-logs/ts-web-extras.build.log +0 -55
  196. package/src/index.browser.ts +0 -24
  197. package/src/index.ts +0 -47
  198. package/src/packlets/crypto-utils/browserCryptoProvider.ts +0 -311
  199. package/src/packlets/crypto-utils/browserHashProvider.ts +0 -73
  200. package/src/packlets/crypto-utils/index.ts +0 -29
  201. package/src/packlets/file-api-types/index.ts +0 -366
  202. package/src/packlets/file-tree/directoryHandleStore.ts +0 -136
  203. package/src/packlets/file-tree/fileApiTreeAccessors.ts +0 -528
  204. package/src/packlets/file-tree/fileSystemAccessTreeAccessors.ts +0 -519
  205. package/src/packlets/file-tree/httpTreeAccessors.ts +0 -448
  206. package/src/packlets/file-tree/index.ts +0 -32
  207. package/src/packlets/file-tree/localStorageTreeAccessors.ts +0 -430
  208. package/src/packlets/helpers/fileTreeHelpers.ts +0 -107
  209. package/src/packlets/helpers/index.ts +0 -28
  210. package/src/packlets/url-utils/index.ts +0 -28
  211. package/src/packlets/url-utils/urlParams.ts +0 -245
  212. package/src/test/mocks/idb-keyval.ts +0 -5
  213. package/src/test/setupTests.ts +0 -87
  214. package/src/test/unit/browserHashProvider.test.ts +0 -155
  215. package/src/test/unit/browserHashProvider.test.ts.bak +0 -376
  216. package/src/test/unit/directoryHandleStore.test.ts +0 -251
  217. package/src/test/unit/fileApiTreeAccessors.test.ts +0 -1387
  218. package/src/test/unit/fileApiTypes.test.ts +0 -587
  219. package/src/test/unit/fileSystemAccessTreeAccessors.test.ts +0 -885
  220. package/src/test/unit/fileTreeHelpers.test.ts +0 -694
  221. package/src/test/unit/httpTreeAccessors.test.ts +0 -1571
  222. package/src/test/unit/localStorageTreeAccessors.test.ts +0 -1014
  223. package/src/test/unit/urlParams.test.ts +0 -464
  224. package/src/test/utils/fileSystemAccessMocks.ts +0 -353
  225. package/src/test/utils/testHelpers.ts +0 -155
  226. package/temp/build/typescript/ts_8nwakTlr.json +0 -1
  227. package/temp/coverage/base.css +0 -224
  228. package/temp/coverage/block-navigation.js +0 -87
  229. package/temp/coverage/crypto-utils/browserCryptoProvider.ts.html +0 -1018
  230. package/temp/coverage/crypto-utils/browserHashProvider.ts.html +0 -304
  231. package/temp/coverage/crypto-utils/index.html +0 -131
  232. package/temp/coverage/favicon.png +0 -0
  233. package/temp/coverage/file-tree/directoryHandleStore.ts.html +0 -493
  234. package/temp/coverage/file-tree/fileApiTreeAccessors.ts.html +0 -1669
  235. package/temp/coverage/file-tree/fileSystemAccessTreeAccessors.ts.html +0 -1642
  236. package/temp/coverage/file-tree/httpTreeAccessors.ts.html +0 -1429
  237. package/temp/coverage/file-tree/index.html +0 -176
  238. package/temp/coverage/file-tree/localStorageTreeAccessors.ts.html +0 -1375
  239. package/temp/coverage/helpers/fileTreeHelpers.ts.html +0 -406
  240. package/temp/coverage/helpers/index.html +0 -116
  241. package/temp/coverage/index.html +0 -161
  242. package/temp/coverage/lcov-report/base.css +0 -224
  243. package/temp/coverage/lcov-report/block-navigation.js +0 -87
  244. package/temp/coverage/lcov-report/crypto-utils/browserCryptoProvider.ts.html +0 -1018
  245. package/temp/coverage/lcov-report/crypto-utils/browserHashProvider.ts.html +0 -304
  246. package/temp/coverage/lcov-report/crypto-utils/index.html +0 -131
  247. package/temp/coverage/lcov-report/favicon.png +0 -0
  248. package/temp/coverage/lcov-report/file-tree/directoryHandleStore.ts.html +0 -493
  249. package/temp/coverage/lcov-report/file-tree/fileApiTreeAccessors.ts.html +0 -1669
  250. package/temp/coverage/lcov-report/file-tree/fileSystemAccessTreeAccessors.ts.html +0 -1642
  251. package/temp/coverage/lcov-report/file-tree/httpTreeAccessors.ts.html +0 -1429
  252. package/temp/coverage/lcov-report/file-tree/index.html +0 -176
  253. package/temp/coverage/lcov-report/file-tree/localStorageTreeAccessors.ts.html +0 -1375
  254. package/temp/coverage/lcov-report/helpers/fileTreeHelpers.ts.html +0 -406
  255. package/temp/coverage/lcov-report/helpers/index.html +0 -116
  256. package/temp/coverage/lcov-report/index.html +0 -161
  257. package/temp/coverage/lcov-report/prettify.css +0 -1
  258. package/temp/coverage/lcov-report/prettify.js +0 -2
  259. package/temp/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  260. package/temp/coverage/lcov-report/sorter.js +0 -210
  261. package/temp/coverage/lcov-report/url-utils/index.html +0 -116
  262. package/temp/coverage/lcov-report/url-utils/urlParams.ts.html +0 -820
  263. package/temp/coverage/lcov.info +0 -3597
  264. package/temp/coverage/prettify.css +0 -1
  265. package/temp/coverage/prettify.js +0 -2
  266. package/temp/coverage/sort-arrow-sprite.png +0 -0
  267. package/temp/coverage/sorter.js +0 -210
  268. package/temp/coverage/url-utils/index.html +0 -116
  269. package/temp/coverage/url-utils/urlParams.ts.html +0 -820
  270. package/temp/test/jest/haste-map-7492f1b44480e0cdd1f220078fb3afd8-c8dd6c3430605adeb2f1cadf4f75e791-8c9336785555d572065b28c111982ba4 +0 -0
  271. package/temp/test/jest/jest-transform-cache-7492f1b44480e0cdd1f220078fb3afd8-79ef2876fae7ca75eedb2aa53dc48338/d6/package_d6e3b0fa94752e16b0b2a2777739b973 +0 -53
  272. package/temp/test/jest/perf-cache-7492f1b44480e0cdd1f220078fb3afd8-da39a3ee5e6b4b0d3255bfef95601890 +0 -1
  273. package/temp/ts-web-extras.api.json +0 -8850
  274. package/temp/ts-web-extras.api.md +0 -433
  275. package/tsconfig.json +0 -7
@@ -1,1229 +0,0 @@
1
- /*
2
- * Copyright (c) 2026 Erik Fortune
3
- *
4
- * Permission is hereby granted, free of charge, to any person obtaining a copy
5
- * of this software and associated documentation files (the "Software"), to deal
6
- * in the Software without restriction, including without limitation the rights
7
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
- * copies of the Software, and to permit persons to whom the Software is
9
- * furnished to do so, subject to the following conditions:
10
- *
11
- * The above copyright notice and this permission notice shall be included in all
12
- * copies or substantial portions of the Software.
13
- *
14
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
- * SOFTWARE.
21
- */
22
- import '@fgv/ts-utils-jest';
23
- import { fail } from '@fgv/ts-utils';
24
- import { HttpTreeAccessors } from '../../packlets/file-tree';
25
- function makeMockResponse(options) {
26
- const { ok, status = ok ? 200 : 400, jsonValue, textValue, throwOnJson, throwOnText } = options;
27
- return {
28
- ok,
29
- status,
30
- statusText: ok ? 'OK' : `Error ${status}`,
31
- json: throwOnJson
32
- ? () => Promise.reject(new Error('JSON parse error'))
33
- : () => Promise.resolve(jsonValue),
34
- text: throwOnText
35
- ? () => Promise.reject(new Error('text error'))
36
- : () => Promise.resolve(textValue !== null && textValue !== void 0 ? textValue : `HTTP ${status}`)
37
- };
38
- }
39
- /**
40
- * Creates a mock fetch function that returns responses for each call in order.
41
- * Each entry maps to one fetch() invocation in call order.
42
- */
43
- function makeMockFetch(responses) {
44
- const calls = [];
45
- let callIndex = 0;
46
- const fetchImpl = (url, init) => {
47
- calls.push({ url: url.toString(), init });
48
- const response = responses[callIndex++];
49
- if (response === undefined) {
50
- return Promise.reject(new Error(`Unexpected fetch call #${callIndex} to ${url.toString()}`));
51
- }
52
- return Promise.resolve(makeMockResponse(response));
53
- };
54
- return { fetchImpl: fetchImpl, calls };
55
- }
56
- /**
57
- * Creates a mock fetch function that throws a network error on every call.
58
- */
59
- function makeThrowingFetch(message) {
60
- return (_url, _init) => {
61
- return Promise.reject(new Error(message));
62
- };
63
- }
64
- // ---- Shared test data builders ----
65
- /** Minimal tree-children response for a single file at root. */
66
- function rootWithOneFile(fileName = 'data.json') {
67
- return {
68
- path: '/',
69
- children: [{ path: `/${fileName}`, name: fileName, type: 'file' }]
70
- };
71
- }
72
- /** File response for a JSON file. */
73
- function fileResponse(path, contents, contentType) {
74
- const response = { path, contents };
75
- if (contentType !== undefined) {
76
- response.contentType = contentType;
77
- }
78
- return response;
79
- }
80
- /** Root children response containing a subdirectory and a file. */
81
- function rootWithDirAndFile() {
82
- return {
83
- path: '/',
84
- children: [
85
- { path: '/subdir', name: 'subdir', type: 'directory' },
86
- { path: '/root.json', name: 'root.json', type: 'file' }
87
- ]
88
- };
89
- }
90
- /** Children response for /subdir containing one file. */
91
- function subdirWithOneFile() {
92
- return {
93
- path: '/subdir',
94
- children: [{ path: '/subdir/nested.json', name: 'nested.json', type: 'file' }]
95
- };
96
- }
97
- // ---- Tests ----
98
- describe('HttpTreeAccessors', () => {
99
- describe('fromHttp()', () => {
100
- test('succeeds with an empty directory (no children)', async () => {
101
- const { fetchImpl } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
102
- const result = await HttpTreeAccessors.fromHttp({
103
- baseUrl: 'http://localhost:3000',
104
- fetchImpl
105
- });
106
- expect(result).toSucceedAndSatisfy((accessors) => {
107
- expect(accessors).toBeInstanceOf(HttpTreeAccessors);
108
- expect(accessors.isDirty()).toBe(false);
109
- });
110
- });
111
- test('succeeds and loads a single file at root', async () => {
112
- const { fetchImpl } = makeMockFetch([
113
- { ok: true, jsonValue: rootWithOneFile('data.json') },
114
- { ok: true, jsonValue: fileResponse('/data.json', '{"items":{}}') }
115
- ]);
116
- const result = await HttpTreeAccessors.fromHttp({
117
- baseUrl: 'http://localhost:3000',
118
- fetchImpl,
119
- mutable: true
120
- });
121
- expect(result).toSucceedAndSatisfy((accessors) => {
122
- expect(accessors.getFileContents('/data.json')).toSucceedWith('{"items":{}}');
123
- });
124
- });
125
- test('succeeds with multiple files at root', async () => {
126
- const { fetchImpl } = makeMockFetch([
127
- {
128
- ok: true,
129
- jsonValue: {
130
- path: '/',
131
- children: [
132
- { path: '/alpha.json', name: 'alpha.json', type: 'file' },
133
- { path: '/beta.json', name: 'beta.json', type: 'file' }
134
- ]
135
- }
136
- },
137
- { ok: true, jsonValue: fileResponse('/alpha.json', '"alpha"') },
138
- { ok: true, jsonValue: fileResponse('/beta.json', '"beta"') }
139
- ]);
140
- const result = await HttpTreeAccessors.fromHttp({
141
- baseUrl: 'http://localhost:3000',
142
- fetchImpl,
143
- mutable: true
144
- });
145
- expect(result).toSucceedAndSatisfy((accessors) => {
146
- expect(accessors.getFileContents('/alpha.json')).toSucceedWith('"alpha"');
147
- expect(accessors.getFileContents('/beta.json')).toSucceedWith('"beta"');
148
- });
149
- });
150
- test('succeeds and recursively loads files in subdirectories', async () => {
151
- const { fetchImpl } = makeMockFetch([
152
- { ok: true, jsonValue: rootWithDirAndFile() },
153
- { ok: true, jsonValue: subdirWithOneFile() },
154
- { ok: true, jsonValue: fileResponse('/subdir/nested.json', '"nested"') },
155
- { ok: true, jsonValue: fileResponse('/root.json', '"root"') }
156
- ]);
157
- const result = await HttpTreeAccessors.fromHttp({
158
- baseUrl: 'http://localhost:3000',
159
- fetchImpl,
160
- mutable: true
161
- });
162
- expect(result).toSucceedAndSatisfy((accessors) => {
163
- expect(accessors.getFileContents('/subdir/nested.json')).toSucceedWith('"nested"');
164
- expect(accessors.getFileContents('/root.json')).toSucceedWith('"root"');
165
- });
166
- });
167
- test('strips trailing slash from baseUrl', async () => {
168
- const { fetchImpl, calls } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
169
- await HttpTreeAccessors.fromHttp({
170
- baseUrl: 'http://localhost:3000/',
171
- fetchImpl
172
- });
173
- // The URL should start with the base URL without a trailing slash, then /tree/children
174
- expect(calls[0].url).toMatch(/^http:\/\/localhost:3000\/tree\/children/);
175
- // There should be no double slash in the path portion (after the protocol)
176
- const urlPath = calls[0].url.replace('http://', '');
177
- expect(urlPath).not.toContain('//');
178
- });
179
- test('includes namespace in query parameters when provided', async () => {
180
- const { fetchImpl, calls } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
181
- await HttpTreeAccessors.fromHttp({
182
- baseUrl: 'http://localhost:3000',
183
- namespace: 'my-namespace',
184
- fetchImpl
185
- });
186
- expect(calls[0].url).toContain('namespace=my-namespace');
187
- });
188
- test('omits namespace from query parameters when not provided', async () => {
189
- const { fetchImpl, calls } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
190
- await HttpTreeAccessors.fromHttp({
191
- baseUrl: 'http://localhost:3000',
192
- fetchImpl
193
- });
194
- expect(calls[0].url).not.toContain('namespace');
195
- });
196
- test('applies prefix parameter to loaded file paths', async () => {
197
- const { fetchImpl } = makeMockFetch([
198
- { ok: true, jsonValue: rootWithOneFile('data.json') },
199
- { ok: true, jsonValue: fileResponse('/data.json', '{"items":{}}') }
200
- ]);
201
- const result = await HttpTreeAccessors.fromHttp({
202
- baseUrl: 'http://localhost:3000',
203
- prefix: '/app',
204
- fetchImpl,
205
- mutable: true
206
- });
207
- expect(result).toSucceedAndSatisfy((accessors) => {
208
- expect(accessors.getFileContents('/app/data.json')).toSucceedWith('{"items":{}}');
209
- });
210
- });
211
- test('fails when the initial children fetch fails with a network error', async () => {
212
- const result = await HttpTreeAccessors.fromHttp({
213
- baseUrl: 'http://localhost:3000',
214
- fetchImpl: makeThrowingFetch('Network unreachable')
215
- });
216
- expect(result).toFailWith(/network unreachable/i);
217
- });
218
- test('fails when the children response is not ok', async () => {
219
- const { fetchImpl } = makeMockFetch([{ ok: false, status: 404, textValue: 'Not Found' }]);
220
- const result = await HttpTreeAccessors.fromHttp({
221
- baseUrl: 'http://localhost:3000',
222
- fetchImpl
223
- });
224
- expect(result).toFailWith(/not found/i);
225
- });
226
- test('fails when the children response contains invalid JSON', async () => {
227
- const { fetchImpl } = makeMockFetch([{ ok: true, throwOnJson: true }]);
228
- const result = await HttpTreeAccessors.fromHttp({
229
- baseUrl: 'http://localhost:3000',
230
- fetchImpl
231
- });
232
- expect(result).toFailWith(/invalid json/i);
233
- });
234
- test('fails when a file fetch fails with a network error', async () => {
235
- let callCount = 0;
236
- const fetchImpl = (_url, _init) => {
237
- callCount++;
238
- if (callCount === 1) {
239
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
240
- }
241
- return Promise.reject(new Error('File fetch failed'));
242
- };
243
- const result = await HttpTreeAccessors.fromHttp({
244
- baseUrl: 'http://localhost:3000',
245
- fetchImpl
246
- });
247
- expect(result).toFailWith(/file fetch failed/i);
248
- });
249
- test('fails when a file response is not ok', async () => {
250
- const { fetchImpl } = makeMockFetch([
251
- { ok: true, jsonValue: rootWithOneFile('data.json') },
252
- { ok: false, status: 403, textValue: 'Forbidden' }
253
- ]);
254
- const result = await HttpTreeAccessors.fromHttp({
255
- baseUrl: 'http://localhost:3000',
256
- fetchImpl
257
- });
258
- expect(result).toFailWith(/forbidden/i);
259
- });
260
- test('fails when a file response contains invalid JSON', async () => {
261
- const { fetchImpl } = makeMockFetch([
262
- { ok: true, jsonValue: rootWithOneFile('data.json') },
263
- { ok: true, throwOnJson: true }
264
- ]);
265
- const result = await HttpTreeAccessors.fromHttp({
266
- baseUrl: 'http://localhost:3000',
267
- fetchImpl
268
- });
269
- expect(result).toFailWith(/invalid json/i);
270
- });
271
- test('fails when a nested directory fetch fails', async () => {
272
- const { fetchImpl } = makeMockFetch([
273
- { ok: true, jsonValue: rootWithDirAndFile() },
274
- { ok: false, status: 500, textValue: 'Internal Server Error' }
275
- ]);
276
- const result = await HttpTreeAccessors.fromHttp({
277
- baseUrl: 'http://localhost:3000',
278
- fetchImpl
279
- });
280
- expect(result).toFailWith(/internal server error/i);
281
- });
282
- });
283
- describe('isDirty() and getDirtyPaths()', () => {
284
- test('starts not dirty after loading', async () => {
285
- const { fetchImpl } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
286
- const accessors = (await HttpTreeAccessors.fromHttp({ baseUrl: 'http://localhost:3000', fetchImpl })).orThrow();
287
- expect(accessors.isDirty()).toBe(false);
288
- expect(accessors.getDirtyPaths()).toEqual([]);
289
- });
290
- test('becomes dirty after saveFileContents', async () => {
291
- const { fetchImpl } = makeMockFetch([
292
- { ok: true, jsonValue: rootWithOneFile('data.json') },
293
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
294
- ]);
295
- const accessors = (await HttpTreeAccessors.fromHttp({
296
- baseUrl: 'http://localhost:3000',
297
- fetchImpl,
298
- mutable: true
299
- })).orThrow();
300
- accessors.saveFileContents('/data.json', '{"modified":true}').orThrow();
301
- expect(accessors.isDirty()).toBe(true);
302
- expect(accessors.getDirtyPaths()).toContain('/data.json');
303
- });
304
- test('tracks multiple dirty files', async () => {
305
- const { fetchImpl } = makeMockFetch([
306
- {
307
- ok: true,
308
- jsonValue: {
309
- path: '/',
310
- children: [
311
- { path: '/a.json', name: 'a.json', type: 'file' },
312
- { path: '/b.json', name: 'b.json', type: 'file' }
313
- ]
314
- }
315
- },
316
- { ok: true, jsonValue: fileResponse('/a.json', '{}') },
317
- { ok: true, jsonValue: fileResponse('/b.json', '{}') }
318
- ]);
319
- const accessors = (await HttpTreeAccessors.fromHttp({
320
- baseUrl: 'http://localhost:3000',
321
- fetchImpl,
322
- mutable: true
323
- })).orThrow();
324
- accessors.saveFileContents('/a.json', '"a"').orThrow();
325
- accessors.saveFileContents('/b.json', '"b"').orThrow();
326
- expect(accessors.getDirtyPaths()).toHaveLength(2);
327
- expect(accessors.getDirtyPaths()).toContain('/a.json');
328
- expect(accessors.getDirtyPaths()).toContain('/b.json');
329
- });
330
- test('tracks deleted files as pending dirty paths', async () => {
331
- const { fetchImpl } = makeMockFetch([
332
- { ok: true, jsonValue: rootWithOneFile('data.json') },
333
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
334
- ]);
335
- const accessors = (await HttpTreeAccessors.fromHttp({
336
- baseUrl: 'http://localhost:3000',
337
- fetchImpl,
338
- mutable: true
339
- })).orThrow();
340
- accessors.deleteFile('/data.json').orThrow();
341
- expect(accessors.isDirty()).toBe(true);
342
- expect(accessors.getDirtyPaths()).toContain('/data.json');
343
- });
344
- });
345
- describe('saveFileContents()', () => {
346
- test('fails when the file is not found (immutable tree has no matching path)', async () => {
347
- const { fetchImpl } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
348
- const accessors = (await HttpTreeAccessors.fromHttp({ baseUrl: 'http://localhost:3000', fetchImpl })).orThrow();
349
- // With mutable: false (default), saveFileContents should fail
350
- const result = accessors.saveFileContents('/missing.json', '{}');
351
- expect(result).toFail();
352
- });
353
- test('marks the file as dirty without autoSync', async () => {
354
- const { fetchImpl } = makeMockFetch([
355
- { ok: true, jsonValue: rootWithOneFile('data.json') },
356
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
357
- ]);
358
- const accessors = (await HttpTreeAccessors.fromHttp({
359
- baseUrl: 'http://localhost:3000',
360
- fetchImpl,
361
- mutable: true
362
- })).orThrow();
363
- const result = accessors.saveFileContents('/data.json', '{"updated":true}');
364
- expect(result).toSucceedWith('{"updated":true}');
365
- expect(accessors.isDirty()).toBe(true);
366
- // No additional fetch calls should have occurred (no autoSync)
367
- });
368
- test('triggers fire-and-forget autoSync when autoSync is enabled', async () => {
369
- const syncResponses = [
370
- { ok: true, jsonValue: rootWithOneFile('data.json') },
371
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
372
- // PUT /file response for sync
373
- { ok: true, jsonValue: { path: '/data.json', contents: '{"auto":true}' } },
374
- // POST /sync response
375
- { ok: true, jsonValue: { synced: 1 } }
376
- ];
377
- const { fetchImpl, calls } = makeMockFetch(syncResponses);
378
- const accessors = (await HttpTreeAccessors.fromHttp({
379
- baseUrl: 'http://localhost:3000',
380
- fetchImpl,
381
- mutable: true,
382
- autoSync: true
383
- })).orThrow();
384
- const result = accessors.saveFileContents('/data.json', '{"auto":true}');
385
- expect(result).toSucceedWith('{"auto":true}');
386
- // autoSync fires-and-forgets; wait for microtasks to drain
387
- await new Promise((resolve) => setTimeout(resolve, 0));
388
- // Verify that PUT + POST /sync were called
389
- const methodCalls = calls.slice(2).map((c) => { var _a; return (_a = c.init) === null || _a === void 0 ? void 0 : _a.method; });
390
- expect(methodCalls).toContain('PUT');
391
- expect(methodCalls).toContain('POST');
392
- });
393
- test('logs when autoSync returns a failure Result', async () => {
394
- const logger = {
395
- detail: jest.fn(),
396
- info: jest.fn(),
397
- warn: jest.fn(),
398
- error: jest.fn()
399
- };
400
- const { fetchImpl } = makeMockFetch([
401
- { ok: true, jsonValue: rootWithOneFile('data.json') },
402
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
403
- ]);
404
- const accessors = (await HttpTreeAccessors.fromHttp({
405
- baseUrl: 'http://localhost:3000',
406
- fetchImpl,
407
- mutable: true,
408
- autoSync: true,
409
- logger
410
- })).orThrow();
411
- accessors.syncToDisk =
412
- async () => fail('simulated sync failure');
413
- accessors.saveFileContents('/data.json', '{"auto":true}').orThrow();
414
- await new Promise((resolve) => setTimeout(resolve, 0));
415
- expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('simulated sync failure'));
416
- });
417
- test('logs when autoSync throws unexpectedly', async () => {
418
- const logger = {
419
- detail: jest.fn(),
420
- info: jest.fn(),
421
- warn: jest.fn(),
422
- error: jest.fn()
423
- };
424
- const { fetchImpl } = makeMockFetch([
425
- { ok: true, jsonValue: rootWithOneFile('data.json') },
426
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
427
- ]);
428
- const accessors = (await HttpTreeAccessors.fromHttp({
429
- baseUrl: 'http://localhost:3000',
430
- fetchImpl,
431
- mutable: true,
432
- autoSync: true,
433
- logger
434
- })).orThrow();
435
- accessors.syncToDisk = async () => {
436
- throw new Error('simulated throw');
437
- };
438
- accessors.saveFileContents('/data.json', '{"auto":true}').orThrow();
439
- await new Promise((resolve) => setTimeout(resolve, 0));
440
- expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('simulated throw'));
441
- });
442
- });
443
- describe('fileIsMutable()', () => {
444
- test('returns persistent detail when file exists and mutable is true', async () => {
445
- const { fetchImpl } = makeMockFetch([
446
- { ok: true, jsonValue: rootWithOneFile('data.json') },
447
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
448
- ]);
449
- const accessors = (await HttpTreeAccessors.fromHttp({
450
- baseUrl: 'http://localhost:3000',
451
- fetchImpl,
452
- mutable: true
453
- })).orThrow();
454
- const result = accessors.fileIsMutable('/data.json');
455
- expect(result.isSuccess()).toBe(true);
456
- if (result.isSuccess()) {
457
- expect(result.value).toBe(true);
458
- expect(result.detail).toBe('persistent');
459
- }
460
- });
461
- test('returns not-mutable detail when mutable is false', async () => {
462
- const { fetchImpl } = makeMockFetch([
463
- { ok: true, jsonValue: rootWithOneFile('data.json') },
464
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
465
- ]);
466
- const accessors = (await HttpTreeAccessors.fromHttp({
467
- baseUrl: 'http://localhost:3000',
468
- fetchImpl,
469
- mutable: false
470
- })).orThrow();
471
- const result = accessors.fileIsMutable('/data.json');
472
- expect(result.isFailure()).toBe(true);
473
- if (result.isFailure()) {
474
- expect(result.detail).toBe('not-mutable');
475
- }
476
- });
477
- test('returns persistent detail for any path when mutable is true (no path existence check)', async () => {
478
- // InMemoryTreeAccessors checks the mutable config, not path existence.
479
- // HttpTreeAccessors layers "persistent" on top of a successful mutable check.
480
- const { fetchImpl } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
481
- const accessors = (await HttpTreeAccessors.fromHttp({
482
- baseUrl: 'http://localhost:3000',
483
- fetchImpl,
484
- mutable: true
485
- })).orThrow();
486
- // Any path succeeds as "persistent" when mutable: true
487
- const result = accessors.fileIsMutable('/nonexistent.json');
488
- expect(result.isSuccess()).toBe(true);
489
- if (result.isSuccess()) {
490
- expect(result.value).toBe(true);
491
- expect(result.detail).toBe('persistent');
492
- }
493
- });
494
- });
495
- describe('syncToDisk()', () => {
496
- test('succeeds immediately with no dirty files', async () => {
497
- const { fetchImpl } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
498
- const accessors = (await HttpTreeAccessors.fromHttp({ baseUrl: 'http://localhost:3000', fetchImpl })).orThrow();
499
- const result = await accessors.syncToDisk();
500
- expect(result).toSucceed();
501
- });
502
- test('PUTs each dirty file then POSTs /sync', async () => {
503
- var _a, _b;
504
- const { fetchImpl, calls } = makeMockFetch([
505
- { ok: true, jsonValue: rootWithOneFile('data.json') },
506
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
507
- { ok: true, jsonValue: fileResponse('/data.json', '{"new":1}') },
508
- { ok: true, jsonValue: { synced: 1 } }
509
- ]);
510
- const accessors = (await HttpTreeAccessors.fromHttp({
511
- baseUrl: 'http://localhost:3000',
512
- fetchImpl,
513
- mutable: true
514
- })).orThrow();
515
- accessors.saveFileContents('/data.json', '{"new":1}').orThrow();
516
- const result = await accessors.syncToDisk();
517
- expect(result).toSucceed();
518
- const syncCalls = calls.slice(2);
519
- expect(syncCalls[0].url).toContain('/file');
520
- expect((_a = syncCalls[0].init) === null || _a === void 0 ? void 0 : _a.method).toBe('PUT');
521
- expect(syncCalls[1].url).toContain('/sync');
522
- expect((_b = syncCalls[1].init) === null || _b === void 0 ? void 0 : _b.method).toBe('POST');
523
- });
524
- test('clears dirty state after successful sync', async () => {
525
- const { fetchImpl } = makeMockFetch([
526
- { ok: true, jsonValue: rootWithOneFile('data.json') },
527
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
528
- { ok: true, jsonValue: fileResponse('/data.json', '"updated"') },
529
- { ok: true, jsonValue: { synced: 1 } }
530
- ]);
531
- const accessors = (await HttpTreeAccessors.fromHttp({
532
- baseUrl: 'http://localhost:3000',
533
- fetchImpl,
534
- mutable: true
535
- })).orThrow();
536
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
537
- expect(accessors.isDirty()).toBe(true);
538
- await accessors.syncToDisk();
539
- expect(accessors.isDirty()).toBe(false);
540
- expect(accessors.getDirtyPaths()).toEqual([]);
541
- });
542
- test('includes namespace in PUT body and POST /sync body when provided', async () => {
543
- var _a, _b;
544
- const { fetchImpl, calls } = makeMockFetch([
545
- { ok: true, jsonValue: rootWithOneFile('data.json') },
546
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
547
- { ok: true, jsonValue: fileResponse('/data.json', '"v2"') },
548
- { ok: true, jsonValue: { synced: 1 } }
549
- ]);
550
- const accessors = (await HttpTreeAccessors.fromHttp({
551
- baseUrl: 'http://localhost:3000',
552
- namespace: 'myns',
553
- fetchImpl,
554
- mutable: true
555
- })).orThrow();
556
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
557
- await accessors.syncToDisk();
558
- const putBody = JSON.parse((_a = calls[2].init) === null || _a === void 0 ? void 0 : _a.body);
559
- expect(putBody.namespace).toBe('myns');
560
- const syncBody = JSON.parse((_b = calls[3].init) === null || _b === void 0 ? void 0 : _b.body);
561
- expect(syncBody.namespace).toBe('myns');
562
- });
563
- test('omits namespace from PUT body and POST /sync body when not provided', async () => {
564
- var _a, _b;
565
- const { fetchImpl, calls } = makeMockFetch([
566
- { ok: true, jsonValue: rootWithOneFile('data.json') },
567
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
568
- { ok: true, jsonValue: fileResponse('/data.json', '"v2"') },
569
- { ok: true, jsonValue: { synced: 1 } }
570
- ]);
571
- const accessors = (await HttpTreeAccessors.fromHttp({
572
- baseUrl: 'http://localhost:3000',
573
- fetchImpl,
574
- mutable: true
575
- })).orThrow();
576
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
577
- await accessors.syncToDisk();
578
- const putBody = JSON.parse((_a = calls[2].init) === null || _a === void 0 ? void 0 : _a.body);
579
- expect(putBody.namespace).toBeUndefined();
580
- const syncBody = JSON.parse((_b = calls[3].init) === null || _b === void 0 ? void 0 : _b.body);
581
- expect(syncBody.namespace).toBeUndefined();
582
- });
583
- test('fails when PUT for a dirty file returns a non-ok response', async () => {
584
- const { fetchImpl } = makeMockFetch([
585
- { ok: true, jsonValue: rootWithOneFile('data.json') },
586
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
587
- { ok: false, status: 500, textValue: 'Server Error' }
588
- ]);
589
- const accessors = (await HttpTreeAccessors.fromHttp({
590
- baseUrl: 'http://localhost:3000',
591
- fetchImpl,
592
- mutable: true
593
- })).orThrow();
594
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
595
- const result = await accessors.syncToDisk();
596
- expect(result).toFailWith(/sync.*data\.json.*server error/i);
597
- });
598
- test('fails when PUT for a dirty file encounters a network error', async () => {
599
- jest.useFakeTimers();
600
- try {
601
- let callCount = 0;
602
- const fetchImpl = (_url, _init) => {
603
- callCount++;
604
- if (callCount === 1) {
605
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
606
- }
607
- if (callCount === 2) {
608
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
609
- }
610
- return Promise.reject(new Error('PUT network error'));
611
- };
612
- const accessors = (await HttpTreeAccessors.fromHttp({
613
- baseUrl: 'http://localhost:3000',
614
- fetchImpl,
615
- mutable: true
616
- })).orThrow();
617
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
618
- const syncPromise = accessors.syncToDisk();
619
- await jest.advanceTimersByTimeAsync(1500);
620
- const result = await syncPromise;
621
- expect(result).toFailWith(/put network error/i);
622
- }
623
- finally {
624
- jest.useRealTimers();
625
- }
626
- });
627
- test('fails when POST /sync returns a non-ok response', async () => {
628
- jest.useFakeTimers();
629
- try {
630
- let callCount = 0;
631
- const fetchImpl = (_url, _init) => {
632
- callCount++;
633
- if (callCount === 1) {
634
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
635
- }
636
- if (callCount === 2) {
637
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
638
- }
639
- if (callCount === 3) {
640
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') }));
641
- }
642
- // All /sync attempts: persistent 503
643
- return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: 'Service Unavailable' }));
644
- };
645
- const accessors = (await HttpTreeAccessors.fromHttp({
646
- baseUrl: 'http://localhost:3000',
647
- fetchImpl,
648
- mutable: true
649
- })).orThrow();
650
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
651
- const syncPromise = accessors.syncToDisk();
652
- // Advance past both backoff delays: 500ms + 1000ms
653
- await jest.advanceTimersByTimeAsync(1500);
654
- const result = await syncPromise;
655
- expect(result).toFailWith(/service unavailable/i);
656
- }
657
- finally {
658
- jest.useRealTimers();
659
- }
660
- });
661
- test('fails when POST /sync encounters a network error', async () => {
662
- jest.useFakeTimers();
663
- try {
664
- let callCount = 0;
665
- const fetchImpl = (_url, _init) => {
666
- callCount++;
667
- if (callCount === 1) {
668
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
669
- }
670
- if (callCount === 2) {
671
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
672
- }
673
- if (callCount === 3) {
674
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') }));
675
- }
676
- return Promise.reject(new Error('POST network error'));
677
- };
678
- const accessors = (await HttpTreeAccessors.fromHttp({
679
- baseUrl: 'http://localhost:3000',
680
- fetchImpl,
681
- mutable: true
682
- })).orThrow();
683
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
684
- const syncPromise = accessors.syncToDisk();
685
- await jest.advanceTimersByTimeAsync(1500);
686
- const result = await syncPromise;
687
- expect(result).toFailWith(/post network error/i);
688
- }
689
- finally {
690
- jest.useRealTimers();
691
- }
692
- });
693
- test('syncs multiple dirty files in order', async () => {
694
- const { fetchImpl, calls } = makeMockFetch([
695
- {
696
- ok: true,
697
- jsonValue: {
698
- path: '/',
699
- children: [
700
- { path: '/a.json', name: 'a.json', type: 'file' },
701
- { path: '/b.json', name: 'b.json', type: 'file' }
702
- ]
703
- }
704
- },
705
- { ok: true, jsonValue: fileResponse('/a.json', '{}') },
706
- { ok: true, jsonValue: fileResponse('/b.json', '{}') },
707
- { ok: true, jsonValue: fileResponse('/a.json', '"a-updated"') },
708
- { ok: true, jsonValue: fileResponse('/b.json', '"b-updated"') },
709
- { ok: true, jsonValue: { synced: 2 } }
710
- ]);
711
- const accessors = (await HttpTreeAccessors.fromHttp({
712
- baseUrl: 'http://localhost:3000',
713
- fetchImpl,
714
- mutable: true
715
- })).orThrow();
716
- accessors.saveFileContents('/a.json', '"a-updated"').orThrow();
717
- accessors.saveFileContents('/b.json', '"b-updated"').orThrow();
718
- const result = await accessors.syncToDisk();
719
- expect(result).toSucceed();
720
- // Verify 2 PUTs + 1 POST
721
- const syncCalls = calls.slice(3);
722
- const methods = syncCalls.map((c) => { var _a; return (_a = c.init) === null || _a === void 0 ? void 0 : _a.method; });
723
- expect(methods.filter((m) => m === 'PUT')).toHaveLength(2);
724
- expect(methods.filter((m) => m === 'POST')).toHaveLength(1);
725
- });
726
- test('DELETEs pending deletions before POST /sync', async () => {
727
- var _a, _b;
728
- const { fetchImpl, calls } = makeMockFetch([
729
- { ok: true, jsonValue: rootWithOneFile('data.json') },
730
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
731
- { ok: true, jsonValue: { deleted: true } },
732
- { ok: true, jsonValue: { synced: 0 } }
733
- ]);
734
- const accessors = (await HttpTreeAccessors.fromHttp({
735
- baseUrl: 'http://localhost:3000',
736
- fetchImpl,
737
- mutable: true
738
- })).orThrow();
739
- accessors.deleteFile('/data.json').orThrow();
740
- const result = await accessors.syncToDisk();
741
- expect(result).toSucceed();
742
- const syncCalls = calls.slice(2);
743
- expect((_a = syncCalls[0].init) === null || _a === void 0 ? void 0 : _a.method).toBe('DELETE');
744
- expect(syncCalls[0].url).toContain('/file?');
745
- expect((_b = syncCalls[1].init) === null || _b === void 0 ? void 0 : _b.method).toBe('POST');
746
- expect(accessors.isDirty()).toBe(false);
747
- });
748
- test('fails when DELETE for pending deletion returns non-ok response', async () => {
749
- const { fetchImpl } = makeMockFetch([
750
- { ok: true, jsonValue: rootWithOneFile('data.json') },
751
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
752
- { ok: false, status: 500, textValue: 'Delete failed' }
753
- ]);
754
- const accessors = (await HttpTreeAccessors.fromHttp({
755
- baseUrl: 'http://localhost:3000',
756
- fetchImpl,
757
- mutable: true
758
- })).orThrow();
759
- accessors.deleteFile('/data.json').orThrow();
760
- const result = await accessors.syncToDisk();
761
- expect(result).toFailWith(/delete.*data\.json.*delete failed/i);
762
- });
763
- });
764
- describe('syncToDisk() - getFileContents failure', () => {
765
- test('fails when getFileContents returns failure for a dirty file during sync', async () => {
766
- // This covers lines 112-113: the contentsResult.isFailure() branch in syncToDisk.
767
- // We load a file, mark it dirty, then sabotage getFileContents to fail.
768
- const { fetchImpl } = makeMockFetch([
769
- { ok: true, jsonValue: rootWithOneFile('data.json') },
770
- { ok: true, jsonValue: fileResponse('/data.json', '{}') }
771
- ]);
772
- const accessors = (await HttpTreeAccessors.fromHttp({
773
- baseUrl: 'http://localhost:3000',
774
- fetchImpl,
775
- mutable: true
776
- })).orThrow();
777
- accessors.saveFileContents('/data.json', '{"new":1}').orThrow();
778
- // Sabotage the base getFileContents to simulate a failure
779
- const originalGet = accessors.getFileContents.bind(accessors);
780
- accessors.getFileContents = (path) => {
781
- if (path === '/data.json') {
782
- // eslint-disable-next-line import/no-internal-modules
783
- const { fail: failResult } = require('@fgv/ts-utils');
784
- return failResult('simulated get failure');
785
- }
786
- return originalGet(path);
787
- };
788
- const result = await accessors.syncToDisk();
789
- expect(result).toFailWith(/simulated get failure/i);
790
- });
791
- });
792
- describe('_request() invalid JSON during sync', () => {
793
- test('fails when PUT /file returns invalid JSON', async () => {
794
- // Covers lines 225-226: _request() returns fail('invalid JSON response')
795
- // when response.json() throws during a sync PUT.
796
- const { fetchImpl } = makeMockFetch([
797
- { ok: true, jsonValue: rootWithOneFile('data.json') },
798
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
799
- // PUT response with ok=true but throwOnJson simulates invalid JSON body
800
- { ok: true, throwOnJson: true }
801
- ]);
802
- const accessors = (await HttpTreeAccessors.fromHttp({
803
- baseUrl: 'http://localhost:3000',
804
- fetchImpl,
805
- mutable: true
806
- })).orThrow();
807
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
808
- const result = await accessors.syncToDisk();
809
- expect(result).toFailWith(/invalid json/i);
810
- });
811
- });
812
- describe('fromHttp() with inferContentType', () => {
813
- test('calls inferContentType when provided and uses the result', async () => {
814
- // Covers line 270: params.inferContentType?.(...).orDefault()
815
- const { fetchImpl } = makeMockFetch([
816
- { ok: true, jsonValue: rootWithOneFile('data.json') },
817
- { ok: true, jsonValue: fileResponse('/data.json', '{}', 'application/json') }
818
- ]);
819
- const result = await HttpTreeAccessors.fromHttp({
820
- baseUrl: 'http://localhost:3000',
821
- fetchImpl,
822
- inferContentType: (path, provided) => {
823
- if (provided === 'application/json') {
824
- const { succeed: s } = require('@fgv/ts-utils');
825
- return s('json');
826
- }
827
- const { succeed: s } = require('@fgv/ts-utils');
828
- return s(undefined);
829
- }
830
- });
831
- expect(result).toSucceed();
832
- });
833
- });
834
- describe('_request() error handling', () => {
835
- test('returns failure with error message for network error (Error instance throw) during sync', async () => {
836
- // Covers line 214 true branch: response.err instanceof Error -> uses .message
837
- let callCount = 0;
838
- const fetchImpl = (_url, _init) => {
839
- callCount++;
840
- if (callCount === 1) {
841
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
842
- }
843
- if (callCount === 2) {
844
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
845
- }
846
- // PUT request throws an Error instance
847
- return Promise.reject(new Error('real Error instance'));
848
- };
849
- const accessors = (await HttpTreeAccessors.fromHttp({
850
- baseUrl: 'http://localhost:3000',
851
- fetchImpl,
852
- mutable: true
853
- })).orThrow();
854
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
855
- const result = await accessors.syncToDisk();
856
- expect(result).toFailWith(/real error instance/i);
857
- });
858
- test('returns failure with error message for non-Error throw during sync PUT', async () => {
859
- // Covers line 214 false branch: thrown value is not an Error instance -> uses String(response.err)
860
- // Must be triggered via syncToDisk() which uses the instance _request() method.
861
- let callCount = 0;
862
- const fetchImpl = (_url, _init) => {
863
- callCount++;
864
- if (callCount === 1) {
865
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
866
- }
867
- if (callCount === 2) {
868
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
869
- }
870
- // Non-Error throw (e.g., a plain string)
871
- return Promise.reject('non-error string rejection');
872
- };
873
- const accessors = (await HttpTreeAccessors.fromHttp({
874
- baseUrl: 'http://localhost:3000',
875
- fetchImpl,
876
- mutable: true
877
- })).orThrow();
878
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
879
- const result = await accessors.syncToDisk();
880
- expect(result).toFailWith(/non-error string rejection/i);
881
- });
882
- test('returns failure with HTTP status fallback when response.text() throws during sync', async () => {
883
- // Covers the text() catch branch in _request(): uses `HTTP ${status}` fallback
884
- // 502 is transient so retries will be attempted; use fake timers to avoid real delays
885
- jest.useFakeTimers();
886
- try {
887
- let callCount = 0;
888
- const fetchImpl = (_url, _init) => {
889
- callCount++;
890
- if (callCount === 1) {
891
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
892
- }
893
- if (callCount === 2) {
894
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
895
- }
896
- return Promise.resolve(makeMockResponse({ ok: false, status: 502, throwOnText: true }));
897
- };
898
- const accessors = (await HttpTreeAccessors.fromHttp({
899
- baseUrl: 'http://localhost:3000',
900
- fetchImpl,
901
- mutable: true
902
- })).orThrow();
903
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
904
- const syncPromise = accessors.syncToDisk();
905
- await jest.advanceTimersByTimeAsync(1500);
906
- const result = await syncPromise;
907
- expect(result).toFailWith(/http 502/i);
908
- }
909
- finally {
910
- jest.useRealTimers();
911
- }
912
- });
913
- });
914
- describe('_requestWithParams() error handling', () => {
915
- test('returns failure with error message for non-Error network throw during init', async () => {
916
- // _requestWithParams is used for GET requests during fromHttp(); test non-Error throw branch
917
- const fetchImpl = (_url, _init) => {
918
- return Promise.reject(42);
919
- };
920
- const result = await HttpTreeAccessors.fromHttp({
921
- baseUrl: 'http://localhost:3000',
922
- fetchImpl
923
- });
924
- expect(result).toFailWith(/42/);
925
- });
926
- test('returns failure with HTTP status fallback when response.text() throws during init', async () => {
927
- const fetchImpl = (_url, _init) => {
928
- return Promise.resolve(makeMockResponse({ ok: false, status: 504, throwOnText: true }));
929
- };
930
- const result = await HttpTreeAccessors.fromHttp({
931
- baseUrl: 'http://localhost:3000',
932
- fetchImpl
933
- });
934
- expect(result).toFailWith(/http 504/i);
935
- });
936
- test('uses globalThis.fetch when no fetchImpl provided', async () => {
937
- // Covers line 48: the `fetchImpl ?? globalThis.fetch` right-side branch.
938
- // We temporarily replace globalThis.fetch with a mock that returns an empty tree.
939
- const originalFetch = globalThis.fetch;
940
- let fetchCallCount = 0;
941
- globalThis.fetch = (_url, _init) => {
942
- fetchCallCount++;
943
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { path: '/', children: [] } }));
944
- };
945
- try {
946
- const result = await HttpTreeAccessors.fromHttp({
947
- baseUrl: 'http://localhost:3000'
948
- // No fetchImpl - should fall back to globalThis.fetch
949
- });
950
- expect(result).toSucceed();
951
- expect(fetchCallCount).toBeGreaterThan(0);
952
- }
953
- finally {
954
- globalThis.fetch = originalFetch;
955
- }
956
- });
957
- });
958
- describe('userId header in _request()', () => {
959
- test('includes X-User-Id header in PUT /file and POST /sync requests when userId is set', async () => {
960
- var _a;
961
- // Covers line 259: the this._userId branch in _request()
962
- const { fetchImpl, calls } = makeMockFetch([
963
- { ok: true, jsonValue: rootWithOneFile('data.json') },
964
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
965
- { ok: true, jsonValue: fileResponse('/data.json', '"updated"') },
966
- { ok: true, jsonValue: { synced: 1 } }
967
- ]);
968
- const accessors = (await HttpTreeAccessors.fromHttp({
969
- baseUrl: 'http://localhost:3000',
970
- fetchImpl,
971
- mutable: true,
972
- userId: 'test-user-123'
973
- })).orThrow();
974
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
975
- const result = await accessors.syncToDisk();
976
- expect(result).toSucceed();
977
- // The PUT and POST requests should include the X-User-Id header
978
- const syncCalls = calls.slice(2);
979
- for (const call of syncCalls) {
980
- const headers = (_a = call.init) === null || _a === void 0 ? void 0 : _a.headers;
981
- expect(headers === null || headers === void 0 ? void 0 : headers['X-User-Id']).toBe('test-user-123');
982
- }
983
- });
984
- });
985
- describe('deleteFile()', () => {
986
- test('fails when the underlying InMemoryTreeAccessors deleteFile fails (file not found)', async () => {
987
- // Covers lines 200-201: result.isFailure() branch in deleteFile
988
- const { fetchImpl } = makeMockFetch([{ ok: true, jsonValue: { path: '/', children: [] } }]);
989
- const accessors = (await HttpTreeAccessors.fromHttp({ baseUrl: 'http://localhost:3000', fetchImpl, mutable: true })).orThrow();
990
- // Delete a file that does not exist — super.deleteFile should fail
991
- const result = accessors.deleteFile('/nonexistent.json');
992
- expect(result).toFail();
993
- });
994
- test('includes namespace in DELETE query params when namespace is configured', async () => {
995
- // Covers lines 131-132: the namespace branch in syncToDisk's pending-deletion loop
996
- const { fetchImpl, calls } = makeMockFetch([
997
- { ok: true, jsonValue: rootWithOneFile('data.json') },
998
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
999
- { ok: true, jsonValue: { deleted: true } },
1000
- { ok: true, jsonValue: { synced: 0 } }
1001
- ]);
1002
- const accessors = (await HttpTreeAccessors.fromHttp({
1003
- baseUrl: 'http://localhost:3000',
1004
- namespace: 'my-namespace',
1005
- fetchImpl,
1006
- mutable: true
1007
- })).orThrow();
1008
- accessors.deleteFile('/data.json').orThrow();
1009
- const result = await accessors.syncToDisk();
1010
- expect(result).toSucceed();
1011
- // The DELETE request URL should include namespace as a query parameter
1012
- const deleteCalls = calls.filter((c) => { var _a; return ((_a = c.init) === null || _a === void 0 ? void 0 : _a.method) === 'DELETE'; });
1013
- expect(deleteCalls).toHaveLength(1);
1014
- expect(deleteCalls[0].url).toContain('namespace=my-namespace');
1015
- });
1016
- test('triggers fire-and-forget autoSync after successful delete when autoSync is enabled', async () => {
1017
- // Covers lines 209-212: the autoSync branch in deleteFile
1018
- const syncResponses = [
1019
- { ok: true, jsonValue: rootWithOneFile('data.json') },
1020
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1021
- // DELETE /file response for pending deletion
1022
- { ok: true, jsonValue: { deleted: true } },
1023
- // POST /sync response
1024
- { ok: true, jsonValue: { synced: 0 } }
1025
- ];
1026
- const { fetchImpl, calls } = makeMockFetch(syncResponses);
1027
- const accessors = (await HttpTreeAccessors.fromHttp({
1028
- baseUrl: 'http://localhost:3000',
1029
- fetchImpl,
1030
- mutable: true,
1031
- autoSync: true
1032
- })).orThrow();
1033
- const result = accessors.deleteFile('/data.json');
1034
- expect(result).toSucceedWith(true);
1035
- // autoSync fires-and-forgets; wait for microtasks to drain
1036
- await new Promise((resolve) => setTimeout(resolve, 0));
1037
- // Verify that DELETE + POST /sync were called
1038
- const methodCalls = calls.slice(2).map((c) => { var _a; return (_a = c.init) === null || _a === void 0 ? void 0 : _a.method; });
1039
- expect(methodCalls).toContain('DELETE');
1040
- expect(methodCalls).toContain('POST');
1041
- });
1042
- });
1043
- describe('_requestWithRetry()', () => {
1044
- test('succeeds on second attempt after a transient 503 error', async () => {
1045
- jest.useFakeTimers();
1046
- try {
1047
- // Responses: init (GET tree/children), then PUT fails with 503, then PUT succeeds, then POST /sync succeeds
1048
- let callCount = 0;
1049
- const fetchImpl = (_url, _init) => {
1050
- callCount++;
1051
- if (callCount === 1) {
1052
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1053
- }
1054
- if (callCount === 2) {
1055
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1056
- }
1057
- // First PUT attempt: transient 503
1058
- if (callCount === 3) {
1059
- return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: '503 Service Unavailable' }));
1060
- }
1061
- // Second PUT attempt: success
1062
- if (callCount === 4) {
1063
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"updated"') }));
1064
- }
1065
- // POST /sync
1066
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1067
- };
1068
- const accessors = (await HttpTreeAccessors.fromHttp({
1069
- baseUrl: 'http://localhost:3000',
1070
- fetchImpl,
1071
- mutable: true
1072
- })).orThrow();
1073
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
1074
- const syncPromise = accessors.syncToDisk();
1075
- // Advance past the 500ms backoff for the first retry
1076
- await jest.advanceTimersByTimeAsync(500);
1077
- const result = await syncPromise;
1078
- expect(result).toSucceed();
1079
- // Three PUT-related calls: init (2) + first PUT attempt (1) + retry PUT (1) + POST sync (1)
1080
- expect(callCount).toBe(5);
1081
- }
1082
- finally {
1083
- jest.useRealTimers();
1084
- }
1085
- });
1086
- test('does not retry on a non-transient error (e.g. 404)', async () => {
1087
- jest.useFakeTimers();
1088
- try {
1089
- let callCount = 0;
1090
- const fetchImpl = (_url, _init) => {
1091
- callCount++;
1092
- if (callCount === 1) {
1093
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1094
- }
1095
- if (callCount === 2) {
1096
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1097
- }
1098
- // PUT attempt: non-transient 404
1099
- return Promise.resolve(makeMockResponse({ ok: false, status: 404, textValue: 'Not Found' }));
1100
- };
1101
- const accessors = (await HttpTreeAccessors.fromHttp({
1102
- baseUrl: 'http://localhost:3000',
1103
- fetchImpl,
1104
- mutable: true
1105
- })).orThrow();
1106
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
1107
- const result = await accessors.syncToDisk();
1108
- // No timers should need advancing - non-transient errors return immediately
1109
- expect(result).toFailWith(/not found/i);
1110
- // Only 3 calls: 2 init + 1 PUT (no retries)
1111
- expect(callCount).toBe(3);
1112
- }
1113
- finally {
1114
- jest.useRealTimers();
1115
- }
1116
- });
1117
- test('returns the last error after exhausting all retries on persistent transient errors', async () => {
1118
- jest.useFakeTimers();
1119
- try {
1120
- let callCount = 0;
1121
- const fetchImpl = (_url, _init) => {
1122
- callCount++;
1123
- if (callCount === 1) {
1124
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1125
- }
1126
- if (callCount === 2) {
1127
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1128
- }
1129
- // All PUT attempts: transient 502 every time
1130
- return Promise.resolve(makeMockResponse({ ok: false, status: 502, textValue: '502 Bad Gateway' }));
1131
- };
1132
- const accessors = (await HttpTreeAccessors.fromHttp({
1133
- baseUrl: 'http://localhost:3000',
1134
- fetchImpl,
1135
- mutable: true
1136
- })).orThrow();
1137
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
1138
- const syncPromise = accessors.syncToDisk();
1139
- // Advance past both backoff delays: 500ms (attempt 1) + 1000ms (attempt 2)
1140
- await jest.advanceTimersByTimeAsync(1500);
1141
- const result = await syncPromise;
1142
- expect(result).toFailWith(/502 bad gateway/i);
1143
- // 2 init + 3 PUT attempts (maxAttempts = 3)
1144
- expect(callCount).toBe(5);
1145
- }
1146
- finally {
1147
- jest.useRealTimers();
1148
- }
1149
- });
1150
- test('logs retry message with method name from init when retrying', async () => {
1151
- jest.useFakeTimers();
1152
- try {
1153
- const logger = {
1154
- detail: jest.fn(),
1155
- info: jest.fn(),
1156
- warn: jest.fn(),
1157
- error: jest.fn()
1158
- };
1159
- let callCount = 0;
1160
- const fetchImpl = (_url, _init) => {
1161
- callCount++;
1162
- if (callCount === 1) {
1163
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1164
- }
1165
- if (callCount === 2) {
1166
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1167
- }
1168
- // First PUT attempt: transient 503
1169
- if (callCount === 3) {
1170
- return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: '503 Service Unavailable' }));
1171
- }
1172
- // Second PUT attempt: success
1173
- if (callCount === 4) {
1174
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"updated"') }));
1175
- }
1176
- // POST /sync
1177
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1178
- };
1179
- const accessors = (await HttpTreeAccessors.fromHttp({
1180
- baseUrl: 'http://localhost:3000',
1181
- fetchImpl,
1182
- mutable: true,
1183
- logger
1184
- })).orThrow();
1185
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
1186
- const syncPromise = accessors.syncToDisk();
1187
- await jest.advanceTimersByTimeAsync(500);
1188
- await syncPromise;
1189
- // The retry log message should include the method name (PUT) from init
1190
- expect(logger.detail).toHaveBeenCalledWith(expect.stringContaining('PUT'));
1191
- }
1192
- finally {
1193
- jest.useRealTimers();
1194
- }
1195
- });
1196
- });
1197
- describe('syncToDisk() concurrency guard', () => {
1198
- test('concurrent syncToDisk calls share the same promise and only make one set of network calls', async () => {
1199
- const { fetchImpl, calls } = makeMockFetch([
1200
- { ok: true, jsonValue: rootWithOneFile('data.json') },
1201
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1202
- // PUT /file for the single sync
1203
- { ok: true, jsonValue: fileResponse('/data.json', '"updated"') },
1204
- // POST /sync
1205
- { ok: true, jsonValue: { synced: 1 } }
1206
- ]);
1207
- const accessors = (await HttpTreeAccessors.fromHttp({
1208
- baseUrl: 'http://localhost:3000',
1209
- fetchImpl,
1210
- mutable: true
1211
- })).orThrow();
1212
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
1213
- // Start two concurrent syncToDisk calls without awaiting the first
1214
- const promise1 = accessors.syncToDisk();
1215
- const promise2 = accessors.syncToDisk();
1216
- const [result1, result2] = await Promise.all([promise1, promise2]);
1217
- expect(result1).toSucceed();
1218
- expect(result2).toSucceed();
1219
- // Both promises should have resolved to the same underlying promise result.
1220
- // Only one PUT and one POST /sync should have been issued (not doubled).
1221
- const syncCalls = calls.slice(2);
1222
- const putCalls = syncCalls.filter((c) => { var _a; return ((_a = c.init) === null || _a === void 0 ? void 0 : _a.method) === 'PUT'; });
1223
- const postCalls = syncCalls.filter((c) => { var _a; return ((_a = c.init) === null || _a === void 0 ? void 0 : _a.method) === 'POST'; });
1224
- expect(putCalls).toHaveLength(1);
1225
- expect(postCalls).toHaveLength(1);
1226
- });
1227
- });
1228
- });
1229
- //# sourceMappingURL=httpTreeAccessors.test.js.map