@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,184 @@
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 { get, type } from '@lowdefy/helpers';
18
+
19
+ import findBlock from './findBlock.js';
20
+ import findPageContext from '../tab/findPageContext.js';
21
+ import getShownPage from '../tab/getShownPage.js';
22
+ import waitForSettle from './waitForSettle.js';
23
+ import waitUntil, { sleep } from './waitUntil.js';
24
+
25
+ const STEPS = ['click', 'fill', 'select', 'press', 'wait', 'screenshot'];
26
+ const SETTLE_MS = 5000;
27
+
28
+ function readOptionLabel(option) {
29
+ if (type.isObject(option)) {
30
+ return String(option.label ?? option.value);
31
+ }
32
+ return String(option);
33
+ }
34
+
35
+ function readOptionValue(option) {
36
+ return type.isObject(option) ? option.value : option;
37
+ }
38
+
39
+ /**
40
+ * Runs the browser's journey steps on the shell, by block id: the dev-tab client's answer to a
41
+ * step-request. A step acts through the engine, as the block's own view does when it is pressed
42
+ * or typed in, and waits for the action chain it started to finish:
43
+ * - `{ click: target }`: the block's onClick.
44
+ * - `{ fill: { blockId, value } }`: sets the input's value, then its onChange.
45
+ * - `{ select: { blockId, value } }`: the option whose label (or value) is `value`.
46
+ * - `{ press: 'Enter' }`: the onPressEnter of the input filled last.
47
+ * - `{ wait: { ms } | { request } | { state } }`.
48
+ * - `{ screenshot: name? }`: the root view as a base64 PNG.
49
+ * Resolves `{ ok: true, settled }` (and `image` for a screenshot); throws when the step cannot run.
50
+ *
51
+ * @param {Object} params
52
+ * @param {() => Promise<string>} params.capture Captures the root view as a base64 PNG.
53
+ * @param {() => Object} params.getLowdefy The open app's lowdefy object.
54
+ * @param {number} [params.timeout] How long a step waits for its block or condition, in ms.
55
+ */
56
+ function createStepRunner({ capture, getLowdefy, timeout = 5000 }) {
57
+ let lastInputBlockId = null;
58
+
59
+ async function settle(lowdefy) {
60
+ const settled = await waitForSettle({ lowdefy, timeout: SETTLE_MS });
61
+ return { ok: true, settled };
62
+ }
63
+
64
+ async function click({ lowdefy, target }) {
65
+ const { block } = await findBlock({ lowdefy, target, timeout });
66
+ if (block.eval.properties?.disabled === true) {
67
+ throw new Error(`Block "${block.blockId}" is disabled.`);
68
+ }
69
+ await block.triggerEvent({ name: 'onClick' });
70
+ return settle(lowdefy);
71
+ }
72
+
73
+ async function findInput({ lowdefy, target }) {
74
+ const found = await findBlock({ lowdefy, target, timeout });
75
+ if (!type.isFunction(found.block.setValue)) {
76
+ throw new Error(`Block "${found.block.blockId}" is not an input.`);
77
+ }
78
+ return found.block;
79
+ }
80
+
81
+ async function setInputValue({ block, lowdefy, value }) {
82
+ block.setValue(value);
83
+ lastInputBlockId = block.blockId;
84
+ await block.triggerEvent({ name: 'onChange' });
85
+ return settle(lowdefy);
86
+ }
87
+
88
+ async function fill({ lowdefy, target }) {
89
+ if (!type.isObject(target) || !Object.hasOwn(target, 'value')) {
90
+ throw new Error(`A fill step needs { blockId, value }. Received ${JSON.stringify(target)}.`);
91
+ }
92
+ const block = await findInput({ lowdefy, target });
93
+ return setInputValue({ block, lowdefy, value: target.value });
94
+ }
95
+
96
+ async function select({ lowdefy, target }) {
97
+ if (!type.isObject(target) || !Object.hasOwn(target, 'value')) {
98
+ throw new Error(
99
+ `A select step needs { blockId, value }. Received ${JSON.stringify(target)}.`
100
+ );
101
+ }
102
+ const block = await findInput({ lowdefy, target });
103
+ const options = block.eval.properties?.options ?? [];
104
+ const option = options.find((item) => readOptionLabel(item) === String(target.value));
105
+ if (option === undefined) {
106
+ throw new Error(
107
+ `Block "${block.blockId}" has no option "${target.value}". Its options are ${JSON.stringify(
108
+ options.map(readOptionLabel)
109
+ )}.`
110
+ );
111
+ }
112
+ return setInputValue({ block, lowdefy, value: readOptionValue(option) });
113
+ }
114
+
115
+ async function press({ key, lowdefy }) {
116
+ if (key !== 'Enter') {
117
+ throw new Error(`A phone presses only "Enter". Received ${JSON.stringify(key)}.`);
118
+ }
119
+ if (lastInputBlockId === null) {
120
+ throw new Error('Press "Enter" needs an input filled before it.');
121
+ }
122
+ const { block } = await findBlock({ lowdefy, target: lastInputBlockId, timeout });
123
+ await block.triggerEvent({ name: 'onPressEnter' });
124
+ return settle(lowdefy);
125
+ }
126
+
127
+ async function waitFor({ lowdefy, until }) {
128
+ if (type.isObject(until) && type.isNumber(until.ms)) {
129
+ await sleep(until.ms);
130
+ return { ok: true };
131
+ }
132
+ if (!type.isObject(until) || !(type.isString(until.request) || type.isString(until.state))) {
133
+ throw new Error(
134
+ `A wait step needs { ms }, { request } or { state }. Received ${JSON.stringify(until)}.`
135
+ );
136
+ }
137
+ const done = await waitUntil({
138
+ timeout,
139
+ check: () => {
140
+ const { context } = findPageContext({ lowdefy, page: getShownPage(lowdefy) });
141
+ if (context === undefined) return undefined;
142
+ if (type.isString(until.request)) {
143
+ const call = context.requests?.[until.request]?.[0];
144
+ return call !== undefined && call.loading !== true ? true : undefined;
145
+ }
146
+ return get(context.state, until.state) !== undefined ? true : undefined;
147
+ },
148
+ });
149
+ if (done === undefined) {
150
+ throw new Error(`Waited ${timeout} ms for ${JSON.stringify(until)}.`);
151
+ }
152
+ return { ok: true };
153
+ }
154
+
155
+ async function run({ step }) {
156
+ if (!type.isObject(step) || Object.keys(step).length !== 1) {
157
+ throw new Error(`A step is an object with one key. Received ${JSON.stringify(step)}.`);
158
+ }
159
+ const [name] = Object.keys(step);
160
+ const lowdefy = getLowdefy();
161
+ switch (name) {
162
+ case 'click':
163
+ return click({ lowdefy, target: step.click });
164
+ case 'fill':
165
+ return fill({ lowdefy, target: step.fill });
166
+ case 'select':
167
+ return select({ lowdefy, target: step.select });
168
+ case 'press':
169
+ return press({ key: step.press, lowdefy });
170
+ case 'wait':
171
+ return waitFor({ lowdefy, until: step.wait });
172
+ case 'screenshot':
173
+ return { ok: true, name: step.screenshot ?? null, image: await capture() };
174
+ default:
175
+ throw new Error(
176
+ `A phone runs the steps ${STEPS.join(', ')}. Received ${JSON.stringify(name)}.`
177
+ );
178
+ }
179
+ }
180
+
181
+ return { run };
182
+ }
183
+
184
+ export default createStepRunner;
@@ -0,0 +1,262 @@
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 React from 'react';
18
+ import { jest } from '@jest/globals';
19
+ import { Pressable, Text, View } from 'react-native';
20
+ import { act, fireEvent, render, screen } from '@testing-library/react-native';
21
+ import { createNativeGlobals, LowdefyPage } from '@lowdefy/client-native';
22
+ import { SetState } from '@lowdefy/actions-core/actions';
23
+ import { _state } from '@lowdefy/operators-js/operators/client';
24
+
25
+ import createMemoryStore from '../../host/createMemoryStore.js';
26
+ import createStepRunner from './createStepRunner.js';
27
+
28
+ // Stand-ins for native blocks: a container, a button, a text input and a selector.
29
+ function Box({ content }) {
30
+ return <View>{content.content?.()}</View>;
31
+ }
32
+ Box.meta = { category: 'container', slots: { content: 'Child blocks.' } };
33
+
34
+ function Button({ methods, properties }) {
35
+ return (
36
+ <Pressable accessibilityRole="button" onPress={() => methods.triggerEvent({ name: 'onClick' })}>
37
+ <Text>{properties.title}</Text>
38
+ </Pressable>
39
+ );
40
+ }
41
+ Button.meta = { category: 'display' };
42
+
43
+ function TextInput({ blockId, value }) {
44
+ return <Text testID={`${blockId}-value`}>{value ?? ''}</Text>;
45
+ }
46
+ TextInput.meta = { category: 'input', valueType: 'string' };
47
+
48
+ function Selector({ blockId, value }) {
49
+ return <Text testID={`${blockId}-value`}>{JSON.stringify(value ?? null)}</Text>;
50
+ }
51
+ Selector.meta = { category: 'input', valueType: 'any' };
52
+
53
+ // An action that takes a while, so a step that returns before its chain ends would be seen.
54
+ function Pause({ params }) {
55
+ return new Promise((resolve) => {
56
+ setTimeout(resolve, params.ms);
57
+ });
58
+ }
59
+
60
+ const types = {
61
+ actions: { Pause, SetState },
62
+ blocks: { Box, Button, Selector, TextInput },
63
+ operators: { _state },
64
+ };
65
+
66
+ function block({ id, type, events, ...rest }) {
67
+ const built = { id: `block:form:${id}:0`, blockId: id, type, ...rest };
68
+ if (events) {
69
+ built.events = Object.fromEntries(
70
+ Object.entries(events).map(([name, actions]) => [name, { try: actions, catch: [] }])
71
+ );
72
+ }
73
+ return built;
74
+ }
75
+
76
+ const pageConfig = {
77
+ id: 'page:form',
78
+ pageId: 'form',
79
+ blockId: 'form',
80
+ type: 'Box',
81
+ path: 'form',
82
+ linkPaths: {},
83
+ subscriptions: [],
84
+ requests: [],
85
+ slots: {
86
+ content: {
87
+ blocks: [
88
+ block({
89
+ id: 'title',
90
+ type: 'TextInput',
91
+ events: {
92
+ onChange: [{ id: 'echo', type: 'SetState', params: { echo: { _state: 'title' } } }],
93
+ onPressEnter: [{ id: 'enter', type: 'SetState', params: { entered: true } }],
94
+ },
95
+ }),
96
+ block({
97
+ id: 'priority',
98
+ type: 'Selector',
99
+ properties: {
100
+ options: [
101
+ { label: 'Low', value: 'low' },
102
+ { label: 'High', value: 'high' },
103
+ ],
104
+ },
105
+ }),
106
+ block({
107
+ id: 'save',
108
+ type: 'Button',
109
+ properties: { title: 'Save' },
110
+ events: {
111
+ onClick: [
112
+ { id: 'pause', type: 'Pause', params: { ms: 200 } },
113
+ { id: 'saved', type: 'SetState', params: { saved: true } },
114
+ ],
115
+ },
116
+ }),
117
+ block({ id: 'locked', type: 'Button', properties: { title: 'Locked', disabled: true } }),
118
+ block({ id: 'hidden', type: 'Button', visible: false, properties: { title: 'Hidden' } }),
119
+ ],
120
+ },
121
+ },
122
+ };
123
+
124
+ const rootConfig = {
125
+ classMap: {},
126
+ home: { configured: true, pageId: 'form' },
127
+ lowdefyApp: { slug: 'test-app' },
128
+ menus: [],
129
+ pagePaths: { form: 'form' },
130
+ theme: { light: {}, dark: {} },
131
+ };
132
+
133
+ async function renderForm() {
134
+ const fetch = jest.fn();
135
+ const host = {
136
+ fetch,
137
+ globals: createNativeGlobals({
138
+ assign: jest.fn(),
139
+ fetch,
140
+ getPath: () => '/form',
141
+ origin: 'https://app.lowdefy.test',
142
+ }),
143
+ onBuildChanged: jest.fn(),
144
+ onCallerChanged: jest.fn(),
145
+ router: { back: jest.fn(), push: jest.fn(), replace: jest.fn() },
146
+ sessionStorage: createMemoryStore(),
147
+ storage: createMemoryStore(),
148
+ WebSocket: jest.fn(),
149
+ websocketUrl: (apiPath) => apiPath,
150
+ };
151
+ const lowdefy = {};
152
+ const core = {
153
+ apiBase: 'https://app.lowdefy.test',
154
+ auth: { authConfig: {} },
155
+ basePath: '',
156
+ lowdefy,
157
+ };
158
+ await render(
159
+ <LowdefyPage
160
+ core={core}
161
+ host={host}
162
+ pageConfig={pageConfig}
163
+ rootConfig={rootConfig}
164
+ types={types}
165
+ />
166
+ );
167
+ await screen.findByText('Save');
168
+ const capture = jest.fn(async () => 'iVBORw0KGgo=');
169
+ const runner = createStepRunner({ capture, getLowdefy: () => lowdefy, timeout: 300 });
170
+ const state = () => lowdefy.contexts['page:form'].state;
171
+ async function run(step) {
172
+ let result;
173
+ await act(async () => {
174
+ result = await runner.run({ step });
175
+ });
176
+ return result;
177
+ }
178
+ async function fail(step) {
179
+ let error;
180
+ await act(async () => {
181
+ error = await runner.run({ step }).catch((caught) => caught);
182
+ });
183
+ return error.message;
184
+ }
185
+ return { capture, fail, run, state };
186
+ }
187
+
188
+ test('a click step finds the block by the id its view carries as testID and waits for its action chain', async () => {
189
+ const { run, state } = await renderForm();
190
+ expect(screen.getByTestId('save')).toBeTruthy();
191
+ const result = await run({ click: 'save' });
192
+ expect(result).toEqual({ ok: true, settled: true });
193
+ expect(state().saved).toBe(true);
194
+ });
195
+
196
+ test('a click step takes a { blockId } target', async () => {
197
+ const { run, state } = await renderForm();
198
+ await run({ click: { blockId: 'save' } });
199
+ expect(state().saved).toBe(true);
200
+ });
201
+
202
+ test('a fill step sets an input and runs its onChange, which the page then shows', async () => {
203
+ const { run, state } = await renderForm();
204
+ await run({ fill: { blockId: 'title', value: 'Quarterly' } });
205
+ expect(state().title).toBe('Quarterly');
206
+ expect(state().echo).toBe('Quarterly');
207
+ expect(screen.getByTestId('title-value').props.children).toBe('Quarterly');
208
+ });
209
+
210
+ test('a press Enter step runs the onPressEnter of the input filled last', async () => {
211
+ const { run, state } = await renderForm();
212
+ await run({ fill: { blockId: 'title', value: 'Quarterly' } });
213
+ await run({ press: 'Enter' });
214
+ expect(state().entered).toBe(true);
215
+ });
216
+
217
+ test('a select step picks the option by its label', async () => {
218
+ const { run, state } = await renderForm();
219
+ await run({ select: { blockId: 'priority', value: 'High' } });
220
+ expect(state().priority).toBe('high');
221
+ });
222
+
223
+ test('a wait step waits for state to be set', async () => {
224
+ const { run, state } = await renderForm();
225
+ await act(async () => {
226
+ fireEvent.press(screen.getByText('Save'));
227
+ });
228
+ expect(state().saved).toBeUndefined();
229
+ expect(await run({ wait: { state: 'saved' } })).toEqual({ ok: true });
230
+ expect(state().saved).toBe(true);
231
+ });
232
+
233
+ test('a screenshot step captures the root view', async () => {
234
+ const { capture, run } = await renderForm();
235
+ expect(await run({ screenshot: 'after-save' })).toEqual({
236
+ ok: true,
237
+ name: 'after-save',
238
+ image: 'iVBORw0KGgo=',
239
+ });
240
+ expect(capture).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ test('steps that cannot run say why', async () => {
244
+ const { fail } = await renderForm();
245
+ expect(await fail({ click: 'missing' })).toBe(
246
+ 'Block "missing" is not on page "form". Waited 300 ms.'
247
+ );
248
+ expect(await fail({ click: 'hidden' })).toBe('Block "hidden" is not visible. Waited 300 ms.');
249
+ expect(await fail({ click: 'locked' })).toBe('Block "locked" is disabled.');
250
+ expect(await fail({ fill: { blockId: 'save', value: 'x' } })).toBe(
251
+ 'Block "save" is not an input.'
252
+ );
253
+ expect(await fail({ select: { blockId: 'priority', value: 'Urgent' } })).toBe(
254
+ 'Block "priority" has no option "Urgent". Its options are ["Low","High"].'
255
+ );
256
+ expect(await fail({ press: 'Enter' })).toBe('Press "Enter" needs an input filled before it.');
257
+ expect(await fail({ press: 'Tab' })).toBe('A phone presses only "Enter". Received "Tab".');
258
+ expect(await fail({ click: { blockId: 'save', text: 'Save' } })).toMatch(/by blockId only/);
259
+ expect(await fail({ goto: 'home' })).toBe(
260
+ 'A phone runs the steps click, fill, select, press, wait, screenshot. Received "goto".'
261
+ );
262
+ });
@@ -0,0 +1,78 @@
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 { type } from '@lowdefy/helpers';
18
+
19
+ import findPageContext from '../tab/findPageContext.js';
20
+ import getShownPage from '../tab/getShownPage.js';
21
+ import waitUntil from './waitUntil.js';
22
+
23
+ // A step's target: a block id, or `{ blockId }`. The browser's grid, text and nth targets read the
24
+ // DOM, which a phone does not have.
25
+ function readBlockId(target) {
26
+ if (type.isString(target)) {
27
+ return target;
28
+ }
29
+ if (type.isObject(target) && type.isString(target.blockId)) {
30
+ const others = Object.keys(target).filter((key) => !['blockId', 'value'].includes(key));
31
+ if (others.length > 0) {
32
+ throw new Error(
33
+ `A step on a phone targets a block by blockId only. Received ${JSON.stringify(target)}.`
34
+ );
35
+ }
36
+ return target.blockId;
37
+ }
38
+ throw new Error(
39
+ `A step target must be a blockId or { blockId }. Received ${JSON.stringify(target)}.`
40
+ );
41
+ }
42
+
43
+ /**
44
+ * The block a step acts on, on the page the shell shows: `{ block, context, page }`. Its view is
45
+ * the one with the block id as testID (client-native BlockLayout), so a step needs no screen
46
+ * position. Waits up to `timeout` ms for the page to render the block and show it.
47
+ */
48
+ async function findBlock({ lowdefy, target, timeout }) {
49
+ const blockId = readBlockId(target);
50
+ let lastError = null;
51
+ const found = await waitUntil({
52
+ timeout,
53
+ check: () => {
54
+ const page = getShownPage(lowdefy);
55
+ const { context, error } = findPageContext({ lowdefy, page });
56
+ if (error) {
57
+ lastError = error;
58
+ return undefined;
59
+ }
60
+ const block = context._internal.RootSlots?.map?.[blockId];
61
+ if (type.isNone(block)) {
62
+ lastError = `Block "${blockId}" is not on page "${page.pageId}".`;
63
+ return undefined;
64
+ }
65
+ if (block.eval?.visible === false) {
66
+ lastError = `Block "${blockId}" is not visible.`;
67
+ return undefined;
68
+ }
69
+ return { block, context, page };
70
+ },
71
+ });
72
+ if (found === undefined) {
73
+ throw new Error(`${lastError} Waited ${timeout} ms.`);
74
+ }
75
+ return found;
76
+ }
77
+
78
+ export default findBlock;
@@ -0,0 +1,37 @@
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
+ // A page context with nothing left running: its onInit and onInitAsync (and the app's) are done, no
18
+ // block event is running (an onClick's action chain, an onMountAsync), no request is loading and
19
+ // every subscription has connected or failed. The dev server's isPageReady reads a browser tab the
20
+ // same way.
21
+ function isContextSettled({ context, lowdefy }) {
22
+ if (context._internal?.onInitDone !== true) return false;
23
+ if (context._internal?.onInitAsyncDone !== true) return false;
24
+ const appContext = lowdefy.appContext;
25
+ if (appContext && appContext._internal?.onInitAsyncDone !== true) return false;
26
+ const blocks = Object.values(context._internal?.RootSlots?.map ?? {});
27
+ const running = blocks.some((block) =>
28
+ Object.values(block?.Events?.events ?? {}).some((event) => event?.loading === true)
29
+ );
30
+ if (running) return false;
31
+ const requests = Object.values(context.requests ?? {});
32
+ if (requests.some((calls) => calls?.[0]?.loading)) return false;
33
+ const channels = Object.values(context.websockets ?? {});
34
+ return channels.every((channel) => channel?.connected === true || Boolean(channel?.error));
35
+ }
36
+
37
+ export default isContextSettled;
@@ -0,0 +1,39 @@
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 findPageContext from '../tab/findPageContext.js';
18
+ import getShownPage from '../tab/getShownPage.js';
19
+ import isContextSettled from './isContextSettled.js';
20
+ import waitUntil from './waitUntil.js';
21
+
22
+ // Waits, at most `timeout` ms, for the page the shell shows (it may have changed: a click can open
23
+ // another page) to finish what an interaction started. True once settled; false when it is still
24
+ // busy, which, as in the browser runner, does not fail the step.
25
+ async function waitForSettle({ lowdefy, timeout }) {
26
+ const settled = await waitUntil({
27
+ timeout,
28
+ check: () => {
29
+ const { context } = findPageContext({ lowdefy, page: getShownPage(lowdefy) });
30
+ if (context === undefined || !isContextSettled({ context, lowdefy })) {
31
+ return undefined;
32
+ }
33
+ return true;
34
+ },
35
+ });
36
+ return settled === true;
37
+ }
38
+
39
+ export default waitForSettle;
@@ -0,0 +1,40 @@
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
+ const POLL_MS = 50;
18
+
19
+ function sleep(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
23
+ // Calls check every 50 ms until it returns a value other than undefined, and resolves with it, or
24
+ // with undefined once `timeout` ms have passed.
25
+ async function waitUntil({ check, timeout }) {
26
+ const deadline = Date.now() + timeout;
27
+ for (;;) {
28
+ const value = check();
29
+ if (value !== undefined) {
30
+ return value;
31
+ }
32
+ if (Date.now() >= deadline) {
33
+ return undefined;
34
+ }
35
+ await sleep(POLL_MS);
36
+ }
37
+ }
38
+
39
+ export { sleep };
40
+ export default waitUntil;