@lowdefy/shell 3.23.2 → 7.2.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 (356) hide show
  1. package/LICENSE +183 -183
  2. package/README.md +364 -2
  3. package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
  4. package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
  5. package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
  6. package/app/+native-intent.js +39 -0
  7. package/app/_layout.js +36 -0
  8. package/app.config.ts +183 -0
  9. package/appLinks.js +65 -0
  10. package/applyShellPlugins.js +43 -0
  11. package/authCallbackIntentFilters.js +46 -0
  12. package/babel.config.js +25 -0
  13. package/checkShellConfig.js +172 -0
  14. package/checkShellPlugins.js +74 -0
  15. package/getPrivacyManifest.js +39 -0
  16. package/metro.config.js +73 -0
  17. package/native/LowdefyAppShortcuts.swift.template +84 -0
  18. package/native/withAppShortcuts.js +60 -0
  19. package/native-fingerprint.json +16 -0
  20. package/package.json +120 -40
  21. package/profiles.js +607 -0
  22. package/readShellConfig.js +36 -0
  23. package/resolveShellPlugins.js +109 -0
  24. package/runtimeVersion.js +112 -0
  25. package/scripts/checkReleaseBundle.mjs +358 -0
  26. package/scripts/createDayjsLocales.mjs +67 -0
  27. package/scripts/createShellAssets.mjs +54 -0
  28. package/scripts/createShellVersion.mjs +83 -0
  29. package/scripts/createTypeManifest.mjs +205 -0
  30. package/shell.dev.json +11 -0
  31. package/shellVariant.js +126 -0
  32. package/src/ShellContext.js +32 -0
  33. package/src/actions/CopyToClipboard.js +29 -0
  34. package/src/actions/CopyToClipboard.test.js +33 -0
  35. package/src/actions/GeolocationCurrentPosition.js +61 -0
  36. package/src/actions/GeolocationCurrentPosition.test.js +79 -0
  37. package/src/actions/GetLocalStorage.js +36 -0
  38. package/src/actions/RemoveLocalStorage.js +31 -0
  39. package/src/actions/ScrollTo.js +66 -0
  40. package/src/actions/ScrollTo.test.js +83 -0
  41. package/src/actions/SetFocus.js +33 -0
  42. package/src/actions/SetFocus.test.js +49 -0
  43. package/src/actions/SetLocalStorage.js +34 -0
  44. package/src/actions/localStorage.test.js +68 -0
  45. package/src/actions/measureInScrollView.js +36 -0
  46. package/src/actions/nativeActions.js +41 -0
  47. package/src/auth/AuthButton.js +44 -0
  48. package/src/auth/AuthGate.js +57 -0
  49. package/src/auth/AuthLink.js +43 -0
  50. package/src/auth/LockScreen.js +67 -0
  51. package/src/auth/LockScreen.test.js +37 -0
  52. package/src/auth/SignInScreen.js +268 -0
  53. package/src/auth/SignInScreen.test.js +187 -0
  54. package/src/auth/createAppAuth.js +325 -0
  55. package/src/auth/createAppAuth.test.js +424 -0
  56. package/src/auth/createAuthApi.js +115 -0
  57. package/src/auth/createAuthApi.test.js +138 -0
  58. package/src/auth/createCoreAuth.js +96 -0
  59. package/src/auth/createCoreAuth.test.js +188 -0
  60. package/src/auth/createDeviceKey.js +62 -0
  61. package/src/auth/createDeviceKey.test.js +54 -0
  62. package/src/auth/createSessionStores.js +45 -0
  63. package/src/auth/createTokenStore.js +103 -0
  64. package/src/auth/createTokenStore.test.js +116 -0
  65. package/src/auth/getAuthSettings.js +24 -0
  66. package/src/auth/getAuthView.js +39 -0
  67. package/src/auth/getAuthView.test.js +52 -0
  68. package/src/auth/getSheetReturnUrl.js +35 -0
  69. package/src/auth/getTokenStoreOptions.js +46 -0
  70. package/src/auth/isBackgroundShell.js +26 -0
  71. package/src/auth/isSheetReturnLink.js +35 -0
  72. package/src/auth/parseQuery.js +39 -0
  73. package/src/auth/purgeAppData.js +80 -0
  74. package/src/auth/purgeAppData.test.js +104 -0
  75. package/src/auth/shouldLock.js +26 -0
  76. package/src/auth/signInWithSheet.js +97 -0
  77. package/src/auth/signInWithSheet.test.js +205 -0
  78. package/src/auth/styles.js +78 -0
  79. package/src/auth/unregisterPushToken.js +62 -0
  80. package/src/auth/unregisterPushToken.test.js +67 -0
  81. package/src/auth/usesBiometricUnlock.js +23 -0
  82. package/src/auth/watchSession.js +47 -0
  83. package/src/auth/withoutCookies.js +26 -0
  84. package/src/cache/createSqliteCacheStore.js +113 -0
  85. package/src/cache/createSqliteCacheStore.test.js +93 -0
  86. package/src/cache/getCacheKey.js +25 -0
  87. package/src/cache/getLastCacheKeyName.js +22 -0
  88. package/src/components/AppProvider.js +118 -0
  89. package/src/components/Frame.js +116 -0
  90. package/src/components/Frame.test.js +101 -0
  91. package/src/components/LoadingScreen.js +35 -0
  92. package/src/components/MenuButton.js +121 -0
  93. package/src/components/MenuButton.test.js +74 -0
  94. package/src/components/MessageScreen.js +51 -0
  95. package/src/components/NavigationTheme.js +33 -0
  96. package/src/components/NoConnectionBanner.js +51 -0
  97. package/src/components/NoConnectionBanner.test.js +73 -0
  98. package/src/components/OtherPageScreen.js +95 -0
  99. package/src/components/OtherPageScreen.test.js +74 -0
  100. package/src/components/PageScreen.js +254 -0
  101. package/src/components/PageScreen.test.js +492 -0
  102. package/src/components/RouteErrorScreen.js +47 -0
  103. package/src/components/ShellRoot.js +34 -0
  104. package/src/components/ShellRoot.test.js +62 -0
  105. package/src/components/ShellThemeProvider.js +45 -0
  106. package/src/components/SwitchToTab.js +36 -0
  107. package/src/components/SwitchToTab.test.js +63 -0
  108. package/src/components/TabIconImages.js +72 -0
  109. package/src/components/UpdateScreen.js +40 -0
  110. package/src/components/getNavigationTheme.js +44 -0
  111. package/src/components/getNavigationTheme.test.js +41 -0
  112. package/src/components/styles.js +50 -0
  113. package/src/components/useSurfaceTokens.js +47 -0
  114. package/src/config/getAppApiBase.js +34 -0
  115. package/src/config/getShellConfig.js +37 -0
  116. package/src/config/getShellConfig.test.js +80 -0
  117. package/src/createShellApp.js +126 -0
  118. package/src/dev/DevShell.js +239 -0
  119. package/src/dev/DevShell.test.js +233 -0
  120. package/src/dev/connect/ConfirmSheet.js +71 -0
  121. package/src/dev/connect/ConnectScreen.js +117 -0
  122. package/src/dev/connect/QrScanner.js +89 -0
  123. package/src/dev/connection/createDevShellConfig.js +47 -0
  124. package/src/dev/connection/createDevTransport.js +51 -0
  125. package/src/dev/connection/createDevTransport.test.js +87 -0
  126. package/src/dev/connection/createServerList.js +48 -0
  127. package/src/dev/connection/isLoopbackHost.js +25 -0
  128. package/src/dev/connection/pairedShellApp.test.js +104 -0
  129. package/src/dev/connection/parseDevLink.js +66 -0
  130. package/src/dev/connection/parseDevLink.test.js +97 -0
  131. package/src/dev/connection/parseQuery.js +33 -0
  132. package/src/dev/connection/parseServerInput.js +40 -0
  133. package/src/dev/connection/parseServerUrl.js +45 -0
  134. package/src/dev/devStyles.js +97 -0
  135. package/src/dev/index.js +21 -0
  136. package/src/dev/inject/createDeviceInjections.js +188 -0
  137. package/src/dev/inject/createDeviceInjections.test.js +224 -0
  138. package/src/dev/inject/createOfflineFetch.js +35 -0
  139. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  140. package/src/dev/inject/injectPush.js +60 -0
  141. package/src/dev/inject/injectShare.js +55 -0
  142. package/src/dev/inject/withInjectedDevice.js +98 -0
  143. package/src/dev/menu/BlockGallery.js +33 -0
  144. package/src/dev/menu/DevMenu.js +121 -0
  145. package/src/dev/menu/createLogBuffer.js +83 -0
  146. package/src/dev/menu/watchShake.js +52 -0
  147. package/src/dev/menu/watchShake.test.js +61 -0
  148. package/src/dev/recorder/createNativeRecorder.js +204 -0
  149. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  150. package/src/dev/redirectDevLink.js +30 -0
  151. package/src/dev/redirectDevLink.test.js +49 -0
  152. package/src/dev/steps/createStepRunner.js +184 -0
  153. package/src/dev/steps/createStepRunner.test.js +262 -0
  154. package/src/dev/steps/findBlock.js +78 -0
  155. package/src/dev/steps/isContextSettled.js +37 -0
  156. package/src/dev/steps/waitForSettle.js +39 -0
  157. package/src/dev/steps/waitUntil.js +40 -0
  158. package/src/dev/tab/DevTabClient.js +97 -0
  159. package/src/dev/tab/answerEval.js +39 -0
  160. package/src/dev/tab/answerInspect.js +45 -0
  161. package/src/dev/tab/createDevTabClient.js +272 -0
  162. package/src/dev/tab/createDevTabClient.test.js +317 -0
  163. package/src/dev/tab/describeTabError.js +35 -0
  164. package/src/dev/tab/devClientMarker.js +22 -0
  165. package/src/dev/tab/devClientMarker.test.js +23 -0
  166. package/src/dev/tab/findPageContext.js +43 -0
  167. package/src/dev/tab/getShownPage.js +42 -0
  168. package/src/dev/tab/readEventStream.js +68 -0
  169. package/src/dev/tab/reloadApp.js +42 -0
  170. package/src/dev/tab/reloadApp.test.js +115 -0
  171. package/src/device/DeviceHosts.js +32 -0
  172. package/src/device/basic/createFilesFeature.js +41 -0
  173. package/src/device/basic/createHapticsFeature.js +46 -0
  174. package/src/device/basic/createKeepAwakeFeature.js +37 -0
  175. package/src/device/basic/createPhotosFeature.js +40 -0
  176. package/src/device/basic/createRequestQueueFeature.js +37 -0
  177. package/src/device/basic/createShareFeature.js +63 -0
  178. package/src/device/basic/createShareFeature.test.js +47 -0
  179. package/src/device/basic/createSpeechFeature.js +43 -0
  180. package/src/device/basic/createUploadFeature.js +41 -0
  181. package/src/device/basic/toImagePickerOptions.js +41 -0
  182. package/src/device/basic/toPickedFile.js +54 -0
  183. package/src/device/calendar/index.js +68 -0
  184. package/src/device/contacts/index.js +67 -0
  185. package/src/device/createDevice.js +65 -0
  186. package/src/device/device.js +27 -0
  187. package/src/device/field/MapView.js +93 -0
  188. package/src/device/field/ScanCodeSheet.js +86 -0
  189. package/src/device/field/ScannerView.js +52 -0
  190. package/src/device/field/createAudioFeature.js +70 -0
  191. package/src/device/field/createBrightnessFeature.js +40 -0
  192. package/src/device/field/createCameraFeature.js +56 -0
  193. package/src/device/field/createDocumentScannerFeature.js +51 -0
  194. package/src/device/field/createLocationFeature.js +39 -0
  195. package/src/device/field/createScanRequests.js +55 -0
  196. package/src/device/field/index.js +39 -0
  197. package/src/device/field/scanRequests.js +22 -0
  198. package/src/device/injected.js +60 -0
  199. package/src/device/injected.test.js +39 -0
  200. package/src/device/nfc/index.js +89 -0
  201. package/src/device/nfc/toNfcRecord.js +44 -0
  202. package/src/device/nfc/toNfcRecord.test.js +48 -0
  203. package/src/device/tracking/createBacklog.js +63 -0
  204. package/src/device/tracking/createBacklog.test.js +47 -0
  205. package/src/device/tracking/index.js +211 -0
  206. package/src/device/tracking/index.test.js +110 -0
  207. package/src/host/createDeviceStore.js +32 -0
  208. package/src/host/createMemoryStore.js +32 -0
  209. package/src/host/createNetworkStatus.js +94 -0
  210. package/src/host/createNetworkStatus.test.js +94 -0
  211. package/src/host/createRouterAdapter.js +33 -0
  212. package/src/host/createShellFetch.js +62 -0
  213. package/src/host/createShellFetch.test.js +165 -0
  214. package/src/host/createShellGlobals.js +62 -0
  215. package/src/host/createShellHost.js +83 -0
  216. package/src/host/createShellHost.test.js +36 -0
  217. package/src/host/getOrigin.js +31 -0
  218. package/src/host/isNetworkError.js +24 -0
  219. package/src/host/isOnline.js +22 -0
  220. package/src/host/networkStatus.js +24 -0
  221. package/src/host/readServerError.js +45 -0
  222. package/src/host/readServerError.test.js +85 -0
  223. package/src/host/reportShellError.js +47 -0
  224. package/src/host/reportShellError.test.js +79 -0
  225. package/src/host/withNetworkErrors.js +38 -0
  226. package/src/host/withNetworkErrors.test.js +61 -0
  227. package/src/host/withShellHeader.js +25 -0
  228. package/src/host/withShellHeader.test.js +28 -0
  229. package/src/js/evaluateJsEntries.js +43 -0
  230. package/src/js/evaluateJsEntries.test.js +48 -0
  231. package/src/locale/createShellLocale.js +112 -0
  232. package/src/locale/createShellLocale.test.js +74 -0
  233. package/src/locale/dayjsLocales.js +165 -0
  234. package/src/locale/getDeviceLocales.js +23 -0
  235. package/src/locale/loadDayjsLocale.js +32 -0
  236. package/src/locale/resolveActiveLocale.js +46 -0
  237. package/src/locale/resolveActiveLocale.test.js +40 -0
  238. package/src/native/backgroundFetch.js +97 -0
  239. package/src/native/callEndpoint.js +63 -0
  240. package/src/native/createAppEventTrigger.js +70 -0
  241. package/src/native/currentApp.js +49 -0
  242. package/src/native/getPagePath.js +32 -0
  243. package/src/native/openPath.js +55 -0
  244. package/src/native/openPath.test.js +39 -0
  245. package/src/native/parseShellLink.js +38 -0
  246. package/src/native/parseShellLink.test.js +29 -0
  247. package/src/native/startAppServices.js +94 -0
  248. package/src/native/startAppServices.test.js +97 -0
  249. package/src/native/startShareIn.js +59 -0
  250. package/src/native/startShortcuts.js +88 -0
  251. package/src/native/startShortcuts.test.js +59 -0
  252. package/src/native/toSharePayload.js +47 -0
  253. package/src/native/toSharePayload.test.js +47 -0
  254. package/src/native/whenAppReady.js +48 -0
  255. package/src/navigation/SlotContext.js +24 -0
  256. package/src/navigation/frameSlots.js +27 -0
  257. package/src/navigation/getFrame.js +30 -0
  258. package/src/navigation/getInitialScreenOptions.js +63 -0
  259. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  260. package/src/navigation/getPresentation.js +28 -0
  261. package/src/navigation/getSlotRootPath.js +27 -0
  262. package/src/navigation/getStackOptions.js +185 -0
  263. package/src/navigation/getTabHref.js +45 -0
  264. package/src/navigation/getTabIcons.js +43 -0
  265. package/src/navigation/isPageUnderHeader.js +46 -0
  266. package/src/navigation/navigation.test.js +342 -0
  267. package/src/navigation/toAppLinkPath.js +43 -0
  268. package/src/navigation/toAppLinkPath.test.js +53 -0
  269. package/src/navigation/toRouterHref.js +29 -0
  270. package/src/notifications/createNotificationsFeature.js +61 -0
  271. package/src/notifications/getCategoryAction.js +31 -0
  272. package/src/notifications/handleNotificationResponse.js +82 -0
  273. package/src/notifications/registerPushToken.js +71 -0
  274. package/src/notifications/startNotifications.js +114 -0
  275. package/src/notifications/toCategoryActions.js +42 -0
  276. package/src/notifications/toNotificationEvent.js +36 -0
  277. package/src/notifications/toScheduleRequest.js +62 -0
  278. package/src/plugins/appTypes.js +28 -0
  279. package/src/plugins/prefixTypes.js +28 -0
  280. package/src/plugins/shellPlugins.json +15 -0
  281. package/src/plugins/typeManifest.js +789 -0
  282. package/src/plugins/types.js +64 -0
  283. package/src/plugins/types.test.js +173 -0
  284. package/src/polyfills/installGetRandomValues.js +28 -0
  285. package/src/queue/createRequestQueue.js +198 -0
  286. package/src/queue/createRequestQueue.test.js +215 -0
  287. package/src/queue/createSqliteQueueStore.js +121 -0
  288. package/src/queue/summarizeQueue.js +39 -0
  289. package/src/session/createAppSession.js +323 -0
  290. package/src/session/createAppSession.test.js +391 -0
  291. package/src/session/getPageView.js +59 -0
  292. package/src/session/getPageView.test.js +137 -0
  293. package/src/sync/encodePagePath.js +27 -0
  294. package/src/sync/fetchPage.js +72 -0
  295. package/src/sync/fetchPage.test.js +84 -0
  296. package/src/sync/fetchRoot.js +43 -0
  297. package/src/sync/fetchUser.js +33 -0
  298. package/src/sync/runLimited.js +36 -0
  299. package/src/sync/toRootConfig.js +35 -0
  300. package/src/test/createFakeNetwork.js +40 -0
  301. package/src/test/createFakeSecureStore.js +40 -0
  302. package/src/test/createFakeServer.js +98 -0
  303. package/src/test/createMemoryCacheStore.js +59 -0
  304. package/src/test/createNetworkError.js +24 -0
  305. package/src/test/expoImage.js +42 -0
  306. package/src/test/expoNotifications.js +40 -0
  307. package/src/test/expoQuickActions.js +22 -0
  308. package/src/test/expoSpeechRecognition.js +31 -0
  309. package/src/test/expoTaskManager.js +25 -0
  310. package/src/test/expoUi.js +55 -0
  311. package/src/test/expoUiBottomSheet.js +64 -0
  312. package/src/test/expoUiDateTimePicker.js +26 -0
  313. package/src/test/expoUiMenu.js +46 -0
  314. package/src/test/expoUiSegmentedControl.js +47 -0
  315. package/src/test/expoUiSwiftUi.js +62 -0
  316. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  317. package/src/test/expoWebBrowser.js +23 -0
  318. package/src/test/fixtures.js +98 -0
  319. package/src/test/nativeModule.js +33 -0
  320. package/src/test/renderPageScreen.js +109 -0
  321. package/src/types/checkTypeSet.js +39 -0
  322. package/src/types/collectPageTypes.js +49 -0
  323. package/src/types/collectPageTypes.test.js +90 -0
  324. package/src/types/findMissingPageTypes.js +40 -0
  325. package/src/version/compareVersions.js +38 -0
  326. package/src/version/isShellTooOld.js +30 -0
  327. package/src/version/shellVersion.js +21 -0
  328. package/src/version/shellVersion.test.js +32 -0
  329. package/src/widgets/fetchWidgetData.js +59 -0
  330. package/src/widgets/index.js +156 -0
  331. package/src/widgets/refreshWidgets.js +31 -0
  332. package/withExcludedNativeModules.js +93 -0
  333. package/dist/location.js +0 -33
  334. package/dist/public/apple-touch-icon.png +0 -0
  335. package/dist/public/icon-32.png +0 -0
  336. package/dist/public/icon-512.png +0 -0
  337. package/dist/public/icon.svg +0 -17
  338. package/dist/public/logo-dark-theme.png +0 -0
  339. package/dist/public/logo-light-theme.png +0 -0
  340. package/dist/public/logo-square-dark-theme.png +0 -0
  341. package/dist/public/logo-square-light-theme.png +0 -0
  342. package/dist/public/manifest.webmanifest +0 -16
  343. package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
  344. package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
  345. package/dist/shell/454.b597ca21366afda000af.js +0 -2
  346. package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
  347. package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
  348. package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
  349. package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
  350. package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
  351. package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
  352. package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
  353. package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
  354. package/dist/shell/index.html +0 -57
  355. package/dist/shell/main.bde87d1433f0625faed6.js +0 -1
  356. package/dist/shell/runtime.1ead93159ba56c05b969.js +0 -1
@@ -0,0 +1,64 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import * as actions from '@lowdefy/actions-core/actions';
18
+ import * as blocks from '@lowdefy/blocks-native/blocks';
19
+ import * as deviceActions from '@lowdefy/blocks-native/actions';
20
+ import * as changeCaseOperators from '@lowdefy/operators-change-case/operators/client';
21
+ import * as cronOperators from '@lowdefy/operators-cron/operators/client';
22
+ import * as dayjsOperators from '@lowdefy/operators-dayjs/operators/client';
23
+ import * as diffOperators from '@lowdefy/operators-diff/operators/client';
24
+ import * as jsOperators from '@lowdefy/operators-js/operators/client';
25
+ import * as jsonataOperators from '@lowdefy/operators-jsonata/operators/client';
26
+ import * as mqlOperators from '@lowdefy/operators-mql/operators/client';
27
+ import * as nunjucksOperators from '@lowdefy/operators-nunjucks/operators/client';
28
+ import * as uuidOperators from '@lowdefy/operators-uuid/operators/client';
29
+ import * as yamlOperators from '@lowdefy/operators-yaml/operators/client';
30
+ import { Screen } from '@lowdefy/client-native';
31
+
32
+ import nativeActions from '../actions/nativeActions.js';
33
+ import appTypes from './appTypes.js';
34
+
35
+ // The plugin maps the shell renders pages with. They hold the packages in shellPlugins.json and the
36
+ // native types of the app's shell plugins (appTypes.js, from shell.json `plugins`), from which
37
+ // scripts/createTypeManifest.mjs writes the type manifest pages are checked against
38
+ // (typeManifest.js); a test keeps the two the same. The blocks are the whole native block set,
39
+ // with the Screen page type from the native renderer (blocks-native carries only its meta). The
40
+ // actions-core actions that reach the DOM on the web run native bodies under the same names, and
41
+ // blocks-native's native-only actions (Haptic, Share, PickFile, ...) reach the phone through the
42
+ // device the shell installs (src/device). The client operators are those of every default operator package,
43
+ // as the build's mobile type map allows. icons is the icon data the native Icon renderer draws
44
+ // from (client-native installs it as components.Icon over this map): each page carries the data
45
+ // of its own icons (_icons) and PageScreen adds them here before the page renders.
46
+ const types = {
47
+ actions: { ...actions, ...deviceActions, ...nativeActions, ...appTypes.actions },
48
+ blocks: { ...blocks, Screen, ...appTypes.blocks },
49
+ icons: {},
50
+ operators: {
51
+ ...jsOperators,
52
+ ...changeCaseOperators,
53
+ ...cronOperators,
54
+ ...dayjsOperators,
55
+ ...diffOperators,
56
+ ...jsonataOperators,
57
+ ...mqlOperators,
58
+ ...nunjucksOperators,
59
+ ...uuidOperators,
60
+ ...yamlOperators,
61
+ },
62
+ };
63
+
64
+ export default types;
@@ -0,0 +1,173 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { execFileSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ import * as nativeBlocks from '@lowdefy/blocks-native/blocks';
23
+ import { Screen } from '@lowdefy/client-native';
24
+
25
+ import nativeActions from '../actions/nativeActions.js';
26
+ import prefixTypes from './prefixTypes.js';
27
+ import typeManifest from './typeManifest.js';
28
+ import types from './types.js';
29
+
30
+ const shellRoot = path.join(__dirname, '../..');
31
+
32
+ function createTypeManifest(env = process.env) {
33
+ return JSON.parse(
34
+ execFileSync(process.execPath, [path.join(shellRoot, 'scripts/createTypeManifest.mjs')], {
35
+ encoding: 'utf8',
36
+ env,
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ })
39
+ );
40
+ }
41
+
42
+ // The test shell plugin (fixtures/printer-plugin) installed as `lowdefy mobile config` installs it,
43
+ // listed in a shell.json, and the types the shell registers with appTypes.js written for it.
44
+ function withPrinterPlugin(plugin, callback) {
45
+ const directory = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'lowdefy-shell-types-'));
46
+ try {
47
+ const nodeModules = path.join(directory, 'node_modules');
48
+ fs.mkdirSync(path.join(nodeModules, '@example'), { recursive: true });
49
+ fs.symlinkSync(
50
+ path.join(shellRoot, 'fixtures/printer-plugin'),
51
+ path.join(nodeModules, '@example/lowdefy-printer'),
52
+ 'dir'
53
+ );
54
+ const devShell = JSON.parse(fs.readFileSync(path.join(shellRoot, 'shell.dev.json'), 'utf8'));
55
+ const configPath = path.join(directory, 'shell.json');
56
+ fs.writeFileSync(configPath, JSON.stringify({ ...devShell, plugins: [plugin] }));
57
+ let pluginTypes;
58
+ jest.isolateModules(() => {
59
+ jest.doMock('./appTypes.js', () => {
60
+ const native = jest.requireActual('../../fixtures/printer-plugin/native.js');
61
+ return {
62
+ __esModule: true,
63
+ default: {
64
+ actions: prefixTypes({ types: native.actions, typePrefix: plugin.typePrefix }),
65
+ blocks: prefixTypes({ types: native.blocks, typePrefix: plugin.typePrefix }),
66
+ },
67
+ };
68
+ });
69
+ pluginTypes = require('./types.js').default;
70
+ });
71
+ return callback({
72
+ env: { ...process.env, LOWDEFY_SHELL_CONFIG: configPath, NODE_PATH: nodeModules },
73
+ pluginTypes,
74
+ });
75
+ } finally {
76
+ fs.rmSync(directory, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ // The committed manifest the bundle carries must be the one shellPlugins.json and the plugin
81
+ // packages make: run `pnpm --filter=@lowdefy/shell type-manifest` after changing either.
82
+ test('src/plugins/typeManifest.js matches the manifest made from shellPlugins.json', () => {
83
+ expect(typeManifest).toEqual(createTypeManifest());
84
+ });
85
+
86
+ // The manifest pages are checked against must name exactly the types the shell registers.
87
+ test('the type manifest lists every type the shell registers, and no other', () => {
88
+ ['actions', 'blocks', 'operators'].forEach((category) => {
89
+ expect(Object.keys(typeManifest[category]).sort()).toEqual(Object.keys(types[category]).sort());
90
+ });
91
+ expect(typeManifest.blocks.Box).toEqual({ package: '@lowdefy/blocks-native', version: 7 });
92
+ expect(typeManifest.blocks.Screen).toEqual({ package: '@lowdefy/blocks-native', version: 7 });
93
+ expect(typeManifest.blocks.TextInput).toEqual({ package: '@lowdefy/blocks-native', version: 7 });
94
+ expect(typeManifest.blocks.Markdown).toEqual({ package: '@lowdefy/blocks-native', version: 7 });
95
+ expect(typeManifest.actions.SetState.package).toBe('@lowdefy/actions-core');
96
+ expect(typeManifest.operators._state.package).toBe('@lowdefy/operators-js');
97
+ });
98
+
99
+ // The build's mobile type map allows the client operators of every default operator package.
100
+ test('the shell registers the client operators of every default operator package', () => {
101
+ [
102
+ ['_change_case', 'change-case'],
103
+ ['_cron', 'cron'],
104
+ ['_dayjs', 'dayjs'],
105
+ ['_diff', 'diff'],
106
+ ['_jsonata', 'jsonata'],
107
+ ['_mql', 'mql'],
108
+ ['_nunjucks', 'nunjucks'],
109
+ ['_uuid', 'uuid'],
110
+ ['_yaml', 'yaml'],
111
+ ].forEach(([operator, name]) => {
112
+ expect(typeof types.operators[operator]).toBe('function');
113
+ expect(typeManifest.operators[operator].package).toBe(`@lowdefy/operators-${name}`);
114
+ });
115
+ });
116
+
117
+ test("the shell renders the whole native block set, with the native renderer's Screen", () => {
118
+ expect(Object.keys(types.blocks).sort()).toEqual([...Object.keys(nativeBlocks), 'Screen'].sort());
119
+ expect(types.blocks.Screen).toBe(Screen);
120
+ expect(types.blocks.Box).toBe(nativeBlocks.Box);
121
+ });
122
+
123
+ test('the DOM-bound actions-core actions run their native bodies under the same names', () => {
124
+ expect(types.actions.SetFocus).toBe(nativeActions.SetFocus);
125
+ expect(types.actions.ScrollTo).toBe(nativeActions.ScrollTo);
126
+ expect(types.actions.CopyToClipboard).toBe(nativeActions.CopyToClipboard);
127
+ expect(types.actions.GetLocalStorage).toBe(nativeActions.GetLocalStorage);
128
+ expect(types.actions.SetLocalStorage).toBe(nativeActions.SetLocalStorage);
129
+ expect(types.actions.RemoveLocalStorage).toBe(nativeActions.RemoveLocalStorage);
130
+ expect(types.actions.SetState).not.toBeUndefined();
131
+ });
132
+
133
+ test("a shell plugin's native types register and join the manifest, named with its typePrefix", () => {
134
+ withPrinterPlugin(
135
+ { name: '@example/lowdefy-printer', version: '2.1.0', typePrefix: 'Printer' },
136
+ ({ env, pluginTypes }) => {
137
+ const manifest = createTypeManifest(env);
138
+ ['actions', 'blocks', 'operators'].forEach((category) => {
139
+ expect(Object.keys(manifest[category]).sort()).toEqual(
140
+ Object.keys(pluginTypes[category]).sort()
141
+ );
142
+ });
143
+ const printer = { package: '@example/lowdefy-printer', version: 2 };
144
+ expect(manifest.blocks.PrinterLabel).toEqual(printer);
145
+ expect(manifest.blocks.PrinterTag).toEqual(printer);
146
+ expect(manifest.actions.PrinterPrint).toEqual(printer);
147
+ expect(manifest.blocks.Label).toBeUndefined();
148
+ expect(manifest.blocks.Tag).toEqual({ package: '@lowdefy/blocks-native', version: 7 });
149
+ // The registry jest.isolateModules loads them in is its own, so they are compared by what
150
+ // they do: the plugin's Label under its prefixed name, the built-in Tag kept.
151
+ expect(
152
+ pluginTypes.blocks.PrinterLabel({ blockId: 'label', properties: { text: 'A1' } })
153
+ ).toEqual({ type: 'PrinterLabel', blockId: 'label', text: 'A1' });
154
+ expect(pluginTypes.actions.PrinterPrint({ params: { text: 'A1' } })).toEqual({
155
+ printed: 'A1',
156
+ });
157
+ expect(pluginTypes.blocks.Tag.meta).toEqual(nativeBlocks.Tag.meta);
158
+ }
159
+ );
160
+ });
161
+
162
+ test('a shell plugin type named like a built-in one fails the manifest: use typePrefix', () => {
163
+ withPrinterPlugin({ name: '@example/lowdefy-printer', version: '2.1.0' }, ({ env }) => {
164
+ expect(() => createTypeManifest(env)).toThrow(
165
+ 'Shell plugin "@example/lowdefy-printer" gives the block type "Tag", which "@lowdefy/blocks-native" already gives. Set a typePrefix on the plugin in mobile.shells[].plugins.'
166
+ );
167
+ });
168
+ });
169
+
170
+ test('appTypes.js ships empty, so a shell without plugins registers only the built-in types', () => {
171
+ const appTypes = jest.requireActual('./appTypes.js').default;
172
+ expect(appTypes).toEqual({ actions: {}, blocks: {} });
173
+ });
@@ -0,0 +1,28 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { getRandomValues } from 'expo-crypto';
18
+
19
+ // The _uuid operator's uuid package draws random ids from crypto.getRandomValues, which Hermes
20
+ // does not have. expo-crypto fills it from the platform's secure random source.
21
+ function installGetRandomValues() {
22
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
23
+ return;
24
+ }
25
+ globalThis.crypto = { ...globalThis.crypto, getRandomValues };
26
+ }
27
+
28
+ export default installGetRandomValues;
@@ -0,0 +1,198 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { getAuthorizationHeaders } from '@lowdefy/client-core';
18
+
19
+ import isNetworkError from '../host/isNetworkError.js';
20
+ import readServerError from '../host/readServerError.js';
21
+ import reportShellError from '../host/reportShellError.js';
22
+ import summarizeQueue from './summarizeQueue.js';
23
+
24
+ const QUEUED_MESSAGE = 'Queued until the phone is back online.';
25
+
26
+ /**
27
+ * The offline request queue: the host's `requestQueue`, which `offline: queue` requests go through
28
+ * (@lowdefy/client-core createCallRequest).
29
+ *
30
+ * - A request is kept when the shell is offline, when the send fails for the network, or when
31
+ * earlier requests still wait (order is kept). Its action resolves with { queued: true, id } as
32
+ * the response. The body, with the payload evaluated when the action ran, is sent unchanged on
33
+ * every try, so a `_uuid` in the payload is a stable idempotency key.
34
+ * - The queue sends in order, one at a time, when the shell comes back online, at launch and on
35
+ * RetryRequestQueue. A 2xx removes the item. A network failure stops the replay and keeps it
36
+ * pending. A 401 stops too: the session ended, and sign-out clears the queue. Any other answer
37
+ * (a validation error, a 409 conflict, a 403) marks it failed with the server's message, kept
38
+ * until RetryRequestQueue or DiscardQueuedRequest; the replay moves on. A request refused for
39
+ * another build goes through the shell's fetch, which syncs and retries it when its page did not
40
+ * change (createShellFetch).
41
+ * - Every change fires the app's onRequestQueue event with { pending, failed, sent, items }.
42
+ *
43
+ * @param {Object} params
44
+ * @param {string} params.appId
45
+ * @param {Function} params.fetch The shell's fetch (createShellFetch).
46
+ * @param {{ isOnline, subscribe }} params.networkStatus
47
+ * @param {() => string} params.newId Makes a queue item id.
48
+ * @param {Object} params.queueStore createSqliteQueueStore.
49
+ * @param {{ get }} params.tokenStore
50
+ * @param {Function} params.triggerAppEvent
51
+ */
52
+ function createRequestQueue({
53
+ appId,
54
+ fetch,
55
+ networkStatus,
56
+ newId,
57
+ queueStore,
58
+ tokenStore,
59
+ triggerAppEvent,
60
+ }) {
61
+ let replaying = null;
62
+
63
+ async function get() {
64
+ return summarizeQueue(await queueStore.list({ appId }));
65
+ }
66
+
67
+ async function announce({ sent = 0 } = {}) {
68
+ const summary = await get();
69
+ triggerAppEvent({ name: 'onRequestQueue', event: { ...summary, sent } });
70
+ return summary;
71
+ }
72
+
73
+ async function enqueue({ body, buildId, pageId, requestId, url }) {
74
+ const id = newId();
75
+ await queueStore.add({
76
+ appId,
77
+ item: { body, buildId, id, pageId, queuedAt: new Date().toISOString(), requestId, url },
78
+ });
79
+ await announce();
80
+ return { response: { queued: true, id, message: QUEUED_MESSAGE } };
81
+ }
82
+
83
+ // Sends one item. 'sent', 'failed', or 'stop' (no network, or the session ended).
84
+ async function sendItem(item) {
85
+ const headers = {
86
+ 'Content-Type': 'application/json',
87
+ ...getAuthorizationHeaders({ host: { authTransport: 'bearer', tokenStore } }),
88
+ };
89
+ if (item.buildId !== null) {
90
+ headers['x-lowdefy-build'] = item.buildId;
91
+ }
92
+ let res;
93
+ try {
94
+ // keepBuild: the item's build is checked, not replaced by the build the shell holds now.
95
+ res = await fetch(item.url, {
96
+ method: 'POST',
97
+ headers,
98
+ body: JSON.stringify(item.body),
99
+ keepBuild: true,
100
+ });
101
+ } catch (error) {
102
+ if (isNetworkError(error)) return 'stop';
103
+ throw error;
104
+ }
105
+ if (res.ok) {
106
+ await queueStore.remove({ id: item.id });
107
+ return 'sent';
108
+ }
109
+ if (res.status === 401) {
110
+ return 'stop';
111
+ }
112
+ await queueStore.update({
113
+ id: item.id,
114
+ status: 'failed',
115
+ error: (await readServerError({ res, message: `The server answered ${res.status}.` }))
116
+ .message,
117
+ attempts: item.attempts + 1,
118
+ });
119
+ return 'failed';
120
+ }
121
+
122
+ async function runReplay() {
123
+ let sent = 0;
124
+ let changed = false;
125
+ const items = await queueStore.list({ appId });
126
+ for (const item of items.filter((entry) => entry.status === 'pending')) {
127
+ if (!networkStatus.isOnline()) break;
128
+ const result = await sendItem(item);
129
+ if (result === 'stop') break;
130
+ changed = true;
131
+ if (result === 'sent') sent += 1;
132
+ }
133
+ if (changed) {
134
+ await announce({ sent });
135
+ }
136
+ }
137
+
138
+ // One replay at a time; a replay asked for while one runs waits for it.
139
+ function replay() {
140
+ if (replaying === null) {
141
+ replaying = runReplay().finally(() => {
142
+ replaying = null;
143
+ });
144
+ }
145
+ return replaying;
146
+ }
147
+
148
+ async function send({ body, buildId, pageId, requestId, send: sendNow, url }) {
149
+ const waiting = (await get()).pending > 0;
150
+ if (!networkStatus.isOnline() || waiting) {
151
+ const queued = await enqueue({ body, buildId, pageId, requestId, url });
152
+ if (networkStatus.isOnline()) replay();
153
+ return queued;
154
+ }
155
+ try {
156
+ return await sendNow();
157
+ } catch (error) {
158
+ if (!isNetworkError(error)) throw error;
159
+ return enqueue({ body, buildId, pageId, requestId, url });
160
+ }
161
+ }
162
+
163
+ async function retry({ id } = {}) {
164
+ const items = await queueStore.list({ appId });
165
+ for (const item of items) {
166
+ if (item.status === 'failed' && (id === undefined || item.id === id)) {
167
+ await queueStore.update({ id: item.id, status: 'pending', attempts: item.attempts });
168
+ }
169
+ }
170
+ await replay();
171
+ return announce();
172
+ }
173
+
174
+ async function discard({ failed, id } = {}) {
175
+ if (failed === true) {
176
+ await queueStore.removeFailed({ appId });
177
+ } else if (typeof id === 'string') {
178
+ await queueStore.remove({ id });
179
+ } else {
180
+ throw new Error('DiscardQueuedRequest requires "id", or "failed: true".');
181
+ }
182
+ return announce();
183
+ }
184
+
185
+ // Replays at launch and whenever the shell comes back online. Returns the function that stops it.
186
+ function start() {
187
+ // No action waits on these replays: a fault (a store that cannot be read) is reported.
188
+ const report = (error) => reportShellError({ error });
189
+ replay().catch(report);
190
+ return networkStatus.subscribe((online) => {
191
+ if (online) replay().catch(report);
192
+ });
193
+ }
194
+
195
+ return { discard, get, replay, retry, send, start };
196
+ }
197
+
198
+ export default createRequestQueue;
@@ -0,0 +1,215 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { jest } from '@jest/globals';
18
+
19
+ import createNetworkError from '../test/createNetworkError.js';
20
+ import createRequestQueue from './createRequestQueue.js';
21
+
22
+ function createMemoryQueueStore() {
23
+ let rows = [];
24
+ return {
25
+ async add({ appId, item }) {
26
+ rows.push({ ...item, appId, status: 'pending', error: null, attempts: 0 });
27
+ },
28
+ async list({ appId }) {
29
+ return rows.filter((row) => row.appId === appId).map((row) => ({ ...row }));
30
+ },
31
+ async update({ attempts, error = null, id, status }) {
32
+ rows = rows.map((row) => (row.id === id ? { ...row, attempts, error, status } : row));
33
+ },
34
+ async remove({ id }) {
35
+ rows = rows.filter((row) => row.id !== id);
36
+ },
37
+ async removeFailed({ appId }) {
38
+ rows = rows.filter((row) => !(row.appId === appId && row.status === 'failed'));
39
+ },
40
+ async removeApp({ appId }) {
41
+ rows = rows.filter((row) => row.appId !== appId);
42
+ },
43
+ };
44
+ }
45
+
46
+ function createNetwork(online) {
47
+ const listeners = new Set();
48
+ return {
49
+ isOnline: () => online,
50
+ set(next) {
51
+ online = next;
52
+ listeners.forEach((listener) => listener(online));
53
+ },
54
+ subscribe(listener) {
55
+ listeners.add(listener);
56
+ return () => listeners.delete(listener);
57
+ },
58
+ };
59
+ }
60
+
61
+ function response(status, body = {}) {
62
+ return { ok: status >= 200 && status < 300, status, json: async () => body };
63
+ }
64
+
65
+ function setup({ online = true, fetch = jest.fn(async () => response(200)) } = {}) {
66
+ let next = 0;
67
+ const networkStatus = createNetwork(online);
68
+ const triggerAppEvent = jest.fn();
69
+ const queue = createRequestQueue({
70
+ appId: 'acme',
71
+ fetch,
72
+ networkStatus,
73
+ newId: () => `q${(next += 1)}`,
74
+ queueStore: createMemoryQueueStore(),
75
+ tokenStore: { get: () => 'token' },
76
+ triggerAppEvent,
77
+ });
78
+ return { fetch, networkStatus, queue, triggerAppEvent };
79
+ }
80
+
81
+ const call = (requestId) => ({
82
+ body: { payload: { id: requestId } },
83
+ buildId: 'b1',
84
+ pageId: 'jobs',
85
+ requestId,
86
+ url: `https://acme.example.com/api/request/jobs/${requestId}`,
87
+ });
88
+
89
+ test('an offline request is kept and answers { queued: true, id }', async () => {
90
+ const { queue, triggerAppEvent } = setup({ online: false });
91
+ const send = jest.fn();
92
+ await expect(queue.send({ ...call('save'), send })).resolves.toEqual({
93
+ response: { queued: true, id: 'q1', message: 'Queued until the phone is back online.' },
94
+ });
95
+ expect(send).not.toHaveBeenCalled();
96
+ expect(triggerAppEvent).toHaveBeenCalledWith({
97
+ name: 'onRequestQueue',
98
+ event: expect.objectContaining({ pending: 1, failed: 0, sent: 0 }),
99
+ });
100
+ });
101
+
102
+ test('an online request sends at once, and is kept when the send fails for the network', async () => {
103
+ const { queue } = setup();
104
+ await expect(queue.send({ ...call('a'), send: async () => ({ response: 1 }) })).resolves.toEqual({
105
+ response: 1,
106
+ });
107
+ await expect(
108
+ queue.send({
109
+ ...call('b'),
110
+ send: async () => {
111
+ throw createNetworkError();
112
+ },
113
+ })
114
+ ).resolves.toEqual(
115
+ expect.objectContaining({ response: expect.objectContaining({ queued: true }) })
116
+ );
117
+ expect((await queue.get()).pending).toBe(1);
118
+ });
119
+
120
+ test('a fault in the send path is thrown, not queued as waiting for the network', async () => {
121
+ const { queue } = setup();
122
+ const fault = new TypeError("Cannot read properties of undefined (reading 'json')");
123
+ await expect(
124
+ queue.send({
125
+ ...call('b'),
126
+ send: async () => {
127
+ throw fault;
128
+ },
129
+ })
130
+ ).rejects.toBe(fault);
131
+ expect((await queue.get()).pending).toBe(0);
132
+ });
133
+
134
+ test('the queue replays in order when the phone comes back online, with the stored body and build', async () => {
135
+ const { fetch, networkStatus, queue, triggerAppEvent } = setup({ online: false });
136
+ await queue.send({ ...call('first'), send: jest.fn() });
137
+ await queue.send({ ...call('second'), send: jest.fn() });
138
+ queue.start();
139
+ networkStatus.set(true);
140
+ await queue.replay();
141
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual([
142
+ 'https://acme.example.com/api/request/jobs/first',
143
+ 'https://acme.example.com/api/request/jobs/second',
144
+ ]);
145
+ expect(fetch.mock.calls[0][1].headers).toEqual(
146
+ expect.objectContaining({ Authorization: 'Bearer token', 'x-lowdefy-build': 'b1' })
147
+ );
148
+ // The shell's fetch keeps the queued build instead of stamping the current one.
149
+ expect(fetch.mock.calls[0][1].keepBuild).toBe(true);
150
+ expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ payload: { id: 'first' } });
151
+ expect(await queue.get()).toEqual({ pending: 0, failed: 0, items: [] });
152
+ expect(triggerAppEvent).toHaveBeenLastCalledWith({
153
+ name: 'onRequestQueue',
154
+ event: expect.objectContaining({ pending: 0, sent: 2 }),
155
+ });
156
+ });
157
+
158
+ test('a refused request is marked failed with the server message, and the replay moves on', async () => {
159
+ const fetch = jest
160
+ .fn()
161
+ .mockResolvedValueOnce(response(409, { message: 'The job was closed.' }))
162
+ .mockResolvedValueOnce(response(200));
163
+ const { networkStatus, queue } = setup({ online: false, fetch });
164
+ await queue.send({ ...call('close'), send: jest.fn() });
165
+ await queue.send({ ...call('note'), send: jest.fn() });
166
+ networkStatus.set(true);
167
+ await queue.replay();
168
+ const summary = await queue.get();
169
+ expect(summary.pending).toBe(0);
170
+ expect(summary.failed).toBe(1);
171
+ expect(summary.items[0]).toEqual(
172
+ expect.objectContaining({ requestId: 'close', status: 'failed', error: 'The job was closed.' })
173
+ );
174
+ });
175
+
176
+ test('a refused request keeps the message of the server error it carries', async () => {
177
+ const fetch = jest.fn().mockResolvedValueOnce(
178
+ response(400, {
179
+ '~e': { name: 'RequestError', message: 'The job is closed.', isLowdefyError: true },
180
+ })
181
+ );
182
+ const { networkStatus, queue } = setup({ online: false, fetch });
183
+ await queue.send({ ...call('close'), send: jest.fn() });
184
+ networkStatus.set(true);
185
+ await queue.replay();
186
+ expect((await queue.get()).items[0]).toEqual(
187
+ expect.objectContaining({ status: 'failed', error: 'The job is closed.' })
188
+ );
189
+ });
190
+
191
+ test('a network failure during the replay stops it and keeps the request pending', async () => {
192
+ const fetch = jest.fn(async () => {
193
+ throw createNetworkError();
194
+ });
195
+ const { networkStatus, queue } = setup({ online: false, fetch });
196
+ await queue.send({ ...call('save'), send: jest.fn() });
197
+ networkStatus.set(true);
198
+ await queue.replay();
199
+ expect((await queue.get()).pending).toBe(1);
200
+ });
201
+
202
+ test('retry sends failed requests again, and discard removes them', async () => {
203
+ const fetch = jest.fn().mockResolvedValue(response(400, { message: 'Bad' }));
204
+ const { networkStatus, queue } = setup({ online: false, fetch });
205
+ await queue.send({ ...call('save'), send: jest.fn() });
206
+ networkStatus.set(true);
207
+ await queue.replay();
208
+ expect((await queue.get()).failed).toBe(1);
209
+ fetch.mockResolvedValue(response(200));
210
+ await expect(queue.retry({ id: 'q1' })).resolves.toEqual({ pending: 0, failed: 0, items: [] });
211
+ await queue.send({ ...call('again'), send: async () => response(200) });
212
+ await expect(queue.discard({})).rejects.toThrow(
213
+ 'DiscardQueuedRequest requires "id", or "failed: true".'
214
+ );
215
+ });