@lowdefy/shell 0.0.0-experimental-20261019052246

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 (239) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +308 -0
  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 +30 -0
  7. package/app/_layout.js +33 -0
  8. package/app.config.ts +168 -0
  9. package/applyShellPlugins.js +43 -0
  10. package/authCallbackIntentFilters.js +46 -0
  11. package/babel.config.js +25 -0
  12. package/checkShellConfig.js +145 -0
  13. package/checkShellPlugins.js +74 -0
  14. package/getPrivacyManifest.js +39 -0
  15. package/metro.config.js +73 -0
  16. package/native-fingerprint.json +12 -0
  17. package/package.json +130 -0
  18. package/profiles.js +274 -0
  19. package/readShellConfig.js +36 -0
  20. package/resolveShellPlugins.js +109 -0
  21. package/runtimeVersion.js +105 -0
  22. package/scripts/checkReleaseBundle.mjs +358 -0
  23. package/scripts/createDayjsLocales.mjs +67 -0
  24. package/scripts/createShellAssets.mjs +54 -0
  25. package/scripts/createShellVersion.mjs +83 -0
  26. package/scripts/createTypeManifest.mjs +205 -0
  27. package/shell.dev.json +11 -0
  28. package/shellVariant.js +111 -0
  29. package/src/ShellContext.js +32 -0
  30. package/src/actions/CopyToClipboard.js +29 -0
  31. package/src/actions/CopyToClipboard.test.js +33 -0
  32. package/src/actions/GeolocationCurrentPosition.js +55 -0
  33. package/src/actions/GeolocationCurrentPosition.test.js +66 -0
  34. package/src/actions/GetLocalStorage.js +36 -0
  35. package/src/actions/RemoveLocalStorage.js +31 -0
  36. package/src/actions/ScrollTo.js +66 -0
  37. package/src/actions/ScrollTo.test.js +83 -0
  38. package/src/actions/SetFocus.js +33 -0
  39. package/src/actions/SetFocus.test.js +49 -0
  40. package/src/actions/SetLocalStorage.js +34 -0
  41. package/src/actions/localStorage.test.js +68 -0
  42. package/src/actions/measureInScrollView.js +36 -0
  43. package/src/actions/nativeActions.js +41 -0
  44. package/src/auth/AuthButton.js +44 -0
  45. package/src/auth/AuthGate.js +57 -0
  46. package/src/auth/AuthLink.js +43 -0
  47. package/src/auth/LockScreen.js +67 -0
  48. package/src/auth/LockScreen.test.js +37 -0
  49. package/src/auth/SignInScreen.js +256 -0
  50. package/src/auth/SignInScreen.test.js +182 -0
  51. package/src/auth/createAppAuth.js +306 -0
  52. package/src/auth/createAppAuth.test.js +414 -0
  53. package/src/auth/createAuthApi.js +99 -0
  54. package/src/auth/createAuthApi.test.js +135 -0
  55. package/src/auth/createCoreAuth.js +96 -0
  56. package/src/auth/createCoreAuth.test.js +188 -0
  57. package/src/auth/createSessionStores.js +45 -0
  58. package/src/auth/createTokenStore.js +103 -0
  59. package/src/auth/createTokenStore.test.js +116 -0
  60. package/src/auth/getAuthSettings.js +24 -0
  61. package/src/auth/getAuthView.js +39 -0
  62. package/src/auth/getAuthView.test.js +52 -0
  63. package/src/auth/getSheetReturnUrl.js +35 -0
  64. package/src/auth/getTokenStoreOptions.js +46 -0
  65. package/src/auth/isBackgroundShell.js +26 -0
  66. package/src/auth/isSheetReturnLink.js +35 -0
  67. package/src/auth/parseQuery.js +39 -0
  68. package/src/auth/purgeAppData.js +45 -0
  69. package/src/auth/shouldLock.js +26 -0
  70. package/src/auth/signInWithSheet.js +97 -0
  71. package/src/auth/signInWithSheet.test.js +205 -0
  72. package/src/auth/styles.js +78 -0
  73. package/src/auth/unregisterPushToken.js +21 -0
  74. package/src/auth/usesBiometricUnlock.js +23 -0
  75. package/src/auth/watchSession.js +47 -0
  76. package/src/auth/withoutCookies.js +26 -0
  77. package/src/cache/createSqliteCacheStore.js +113 -0
  78. package/src/cache/createSqliteCacheStore.test.js +93 -0
  79. package/src/cache/getCacheKey.js +25 -0
  80. package/src/cache/getLastCacheKeyName.js +22 -0
  81. package/src/components/AppProvider.js +90 -0
  82. package/src/components/Frame.js +116 -0
  83. package/src/components/Frame.test.js +101 -0
  84. package/src/components/LoadingScreen.js +35 -0
  85. package/src/components/MenuButton.js +108 -0
  86. package/src/components/MenuButton.test.js +51 -0
  87. package/src/components/MessageScreen.js +51 -0
  88. package/src/components/NavigationTheme.js +33 -0
  89. package/src/components/NoConnectionBanner.js +51 -0
  90. package/src/components/NoConnectionBanner.test.js +73 -0
  91. package/src/components/OtherPageScreen.js +56 -0
  92. package/src/components/PageScreen.js +244 -0
  93. package/src/components/PageScreen.test.js +492 -0
  94. package/src/components/ShellRoot.js +34 -0
  95. package/src/components/ShellRoot.test.js +62 -0
  96. package/src/components/ShellThemeProvider.js +45 -0
  97. package/src/components/SwitchToTab.js +36 -0
  98. package/src/components/SwitchToTab.test.js +63 -0
  99. package/src/components/TabIconImages.js +72 -0
  100. package/src/components/UpdateScreen.js +36 -0
  101. package/src/components/getNavigationTheme.js +44 -0
  102. package/src/components/getNavigationTheme.test.js +41 -0
  103. package/src/components/styles.js +50 -0
  104. package/src/components/useSurfaceTokens.js +47 -0
  105. package/src/config/getAppApiBase.js +31 -0
  106. package/src/config/getShellConfig.js +35 -0
  107. package/src/config/getShellConfig.test.js +80 -0
  108. package/src/createShellApp.js +105 -0
  109. package/src/dev/DevShell.js +228 -0
  110. package/src/dev/DevShell.test.js +233 -0
  111. package/src/dev/connect/ConfirmSheet.js +71 -0
  112. package/src/dev/connect/ConnectScreen.js +117 -0
  113. package/src/dev/connect/QrScanner.js +89 -0
  114. package/src/dev/connection/createDevShellConfig.js +47 -0
  115. package/src/dev/connection/createDevTransport.js +51 -0
  116. package/src/dev/connection/createDevTransport.test.js +87 -0
  117. package/src/dev/connection/createServerList.js +48 -0
  118. package/src/dev/connection/isLoopbackHost.js +25 -0
  119. package/src/dev/connection/pairedShellApp.test.js +104 -0
  120. package/src/dev/connection/parseDevLink.js +66 -0
  121. package/src/dev/connection/parseDevLink.test.js +97 -0
  122. package/src/dev/connection/parseQuery.js +33 -0
  123. package/src/dev/connection/parseServerInput.js +40 -0
  124. package/src/dev/connection/parseServerUrl.js +45 -0
  125. package/src/dev/devStyles.js +97 -0
  126. package/src/dev/index.js +21 -0
  127. package/src/dev/inject/createDeviceInjections.js +185 -0
  128. package/src/dev/inject/createDeviceInjections.test.js +208 -0
  129. package/src/dev/inject/createOfflineFetch.js +35 -0
  130. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  131. package/src/dev/menu/BlockGallery.js +33 -0
  132. package/src/dev/menu/DevMenu.js +121 -0
  133. package/src/dev/menu/createLogBuffer.js +83 -0
  134. package/src/dev/menu/watchShake.js +52 -0
  135. package/src/dev/menu/watchShake.test.js +61 -0
  136. package/src/dev/recorder/createNativeRecorder.js +204 -0
  137. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  138. package/src/dev/redirectDevLink.js +30 -0
  139. package/src/dev/redirectDevLink.test.js +49 -0
  140. package/src/dev/steps/createStepRunner.js +184 -0
  141. package/src/dev/steps/createStepRunner.test.js +262 -0
  142. package/src/dev/steps/findBlock.js +78 -0
  143. package/src/dev/steps/isContextSettled.js +37 -0
  144. package/src/dev/steps/waitForSettle.js +39 -0
  145. package/src/dev/steps/waitUntil.js +40 -0
  146. package/src/dev/tab/DevTabClient.js +97 -0
  147. package/src/dev/tab/answerEval.js +39 -0
  148. package/src/dev/tab/answerInspect.js +45 -0
  149. package/src/dev/tab/answerLoadState.js +50 -0
  150. package/src/dev/tab/createDevTabClient.js +277 -0
  151. package/src/dev/tab/createDevTabClient.test.js +314 -0
  152. package/src/dev/tab/devClientMarker.js +22 -0
  153. package/src/dev/tab/devClientMarker.test.js +23 -0
  154. package/src/dev/tab/findPageContext.js +43 -0
  155. package/src/dev/tab/getShownPage.js +42 -0
  156. package/src/dev/tab/readEventStream.js +68 -0
  157. package/src/dev/tab/reloadApp.js +42 -0
  158. package/src/dev/tab/reloadApp.test.js +115 -0
  159. package/src/device/injected.js +60 -0
  160. package/src/device/injected.test.js +39 -0
  161. package/src/host/createDeviceStore.js +32 -0
  162. package/src/host/createMemoryStore.js +32 -0
  163. package/src/host/createNetworkStatus.js +94 -0
  164. package/src/host/createNetworkStatus.test.js +94 -0
  165. package/src/host/createRouterAdapter.js +33 -0
  166. package/src/host/createShellFetch.js +60 -0
  167. package/src/host/createShellFetch.test.js +150 -0
  168. package/src/host/createShellGlobals.js +62 -0
  169. package/src/host/createShellHost.js +73 -0
  170. package/src/host/createShellHost.test.js +36 -0
  171. package/src/host/getOrigin.js +26 -0
  172. package/src/host/isOnline.js +22 -0
  173. package/src/host/networkStatus.js +24 -0
  174. package/src/host/withShellHeader.js +25 -0
  175. package/src/host/withShellHeader.test.js +28 -0
  176. package/src/js/evaluateJsEntries.js +43 -0
  177. package/src/js/evaluateJsEntries.test.js +48 -0
  178. package/src/locale/createShellLocale.js +112 -0
  179. package/src/locale/createShellLocale.test.js +74 -0
  180. package/src/locale/dayjsLocales.js +165 -0
  181. package/src/locale/getDeviceLocales.js +23 -0
  182. package/src/locale/loadDayjsLocale.js +32 -0
  183. package/src/locale/resolveActiveLocale.js +46 -0
  184. package/src/locale/resolveActiveLocale.test.js +40 -0
  185. package/src/navigation/SlotContext.js +24 -0
  186. package/src/navigation/frameSlots.js +27 -0
  187. package/src/navigation/getFrame.js +30 -0
  188. package/src/navigation/getInitialScreenOptions.js +63 -0
  189. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  190. package/src/navigation/getPresentation.js +28 -0
  191. package/src/navigation/getSlotRootPath.js +27 -0
  192. package/src/navigation/getStackOptions.js +185 -0
  193. package/src/navigation/getTabHref.js +45 -0
  194. package/src/navigation/getTabIcons.js +43 -0
  195. package/src/navigation/isPageUnderHeader.js +46 -0
  196. package/src/navigation/navigation.test.js +342 -0
  197. package/src/navigation/toRouterHref.js +29 -0
  198. package/src/plugins/appTypes.js +28 -0
  199. package/src/plugins/prefixTypes.js +28 -0
  200. package/src/plugins/shellPlugins.json +14 -0
  201. package/src/plugins/typeManifest.js +649 -0
  202. package/src/plugins/types.js +61 -0
  203. package/src/plugins/types.test.js +173 -0
  204. package/src/polyfills/installGetRandomValues.js +28 -0
  205. package/src/session/createAppSession.js +318 -0
  206. package/src/session/createAppSession.test.js +359 -0
  207. package/src/session/getPageView.js +59 -0
  208. package/src/session/getPageView.test.js +137 -0
  209. package/src/sync/encodePagePath.js +27 -0
  210. package/src/sync/fetchPage.js +49 -0
  211. package/src/sync/fetchRoot.js +38 -0
  212. package/src/sync/fetchUser.js +28 -0
  213. package/src/sync/runLimited.js +36 -0
  214. package/src/sync/toRootConfig.js +35 -0
  215. package/src/test/createFakeNetwork.js +40 -0
  216. package/src/test/createFakeSecureStore.js +40 -0
  217. package/src/test/createFakeServer.js +98 -0
  218. package/src/test/createMemoryCacheStore.js +59 -0
  219. package/src/test/expoImage.js +38 -0
  220. package/src/test/expoSpeechRecognition.js +31 -0
  221. package/src/test/expoUi.js +55 -0
  222. package/src/test/expoUiBottomSheet.js +64 -0
  223. package/src/test/expoUiDateTimePicker.js +26 -0
  224. package/src/test/expoUiMenu.js +46 -0
  225. package/src/test/expoUiSegmentedControl.js +47 -0
  226. package/src/test/expoUiSwiftUi.js +62 -0
  227. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  228. package/src/test/expoWebBrowser.js +23 -0
  229. package/src/test/fixtures.js +98 -0
  230. package/src/test/renderPageScreen.js +109 -0
  231. package/src/types/checkTypeSet.js +39 -0
  232. package/src/types/collectPageTypes.js +49 -0
  233. package/src/types/collectPageTypes.test.js +90 -0
  234. package/src/types/findMissingPageTypes.js +40 -0
  235. package/src/version/compareVersions.js +38 -0
  236. package/src/version/isShellTooOld.js +30 -0
  237. package/src/version/shellVersion.js +21 -0
  238. package/src/version/shellVersion.test.js +32 -0
  239. package/withExcludedNativeModules.js +93 -0
@@ -0,0 +1,277 @@
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
+ import devPassiveHeader from '@lowdefy/node-utils/devPassiveHeader.js';
19
+
20
+ import answerEval from './answerEval.js';
21
+ import answerInspect from './answerInspect.js';
22
+ import answerLoadState from './answerLoadState.js';
23
+ import DEV_CLIENT_MARKER from './devClientMarker.js';
24
+ import getShownPage from './getShownPage.js';
25
+ import readEventStream from './readEventStream.js';
26
+
27
+ const FIRST_EVENT_MS = 5000;
28
+ const RECONNECT_MS = 1000;
29
+ const PAGE_CHECK_MS = 500;
30
+
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+
35
+ // The phone's platform and app id ride on every registration, so the dev server's device tools
36
+ // know a simulator from an Android device (server-dev src/routes/reload.js).
37
+ function deviceQuery(device) {
38
+ const params = new URLSearchParams({ client: DEV_CLIENT_MARKER });
39
+ if (type.isString(device?.platform)) {
40
+ params.set('platform', device.platform);
41
+ }
42
+ if (type.isString(device?.app)) {
43
+ params.set('app', device.app);
44
+ }
45
+ return params.toString();
46
+ }
47
+
48
+ // The instance a request names (the dev server picks it), else the one on screen.
49
+ function requestedPage({ request, shownPage }) {
50
+ if (type.isString(request.instanceKey)) {
51
+ return {
52
+ pageId: request.pageId,
53
+ pathParams: request.pathParams,
54
+ instanceKey: request.instanceKey,
55
+ };
56
+ }
57
+ return shownPage;
58
+ }
59
+
60
+ /**
61
+ * Lowdefy Dev's dev-tab client: the shell joins the dev server's tab channel as a browser tab does
62
+ * (server-dev src/routes/reload.js, lib/docs/tabChannel.js, src/routes/devInspect.js), so agents
63
+ * reach it with the tools they use on a browser tab.
64
+ *
65
+ * - It registers on /api/reload, reading the event stream from a streaming fetch body. When the
66
+ * stream cannot be read (no `tab` event within 5 s), it polls /api/reload?transport=poll every
67
+ * second instead.
68
+ * - It posts the page instance on screen to /api/dev-inspect/page whenever it changes.
69
+ * - It names the phone's platform and app id (`device`) when it registers, for the device tools.
70
+ * - `reload` runs onReload. `inspect-request` and `eval-request` are answered on /api/dev-inspect
71
+ * with the payloads a browser tab posts; `load-state-request` sets a state checkpoint on the page
72
+ * (`{ requestId, state, verify }`); `step-request` runs a journey step (onStep), and
73
+ * `inject` fakes the next device result (onInject: `{ requestId, inject: { kind, value, name } }`).
74
+ *
75
+ * @param {Object} params
76
+ * @param {string} params.apiBase The dev server's URL.
77
+ * @param {{ platform: string, app: string }} [params.device] The phone's platform ('ios' or
78
+ * 'android') and app id (bundle id or package).
79
+ * @param {Function} params.fetch The Fetch API for posts and polls (pairing and shell headers).
80
+ * @param {Function} params.streamFetch A Fetch API whose response body streams (expo/fetch), with
81
+ * the same headers.
82
+ * @param {() => Object|null} params.getLowdefy The open app's lowdefy object.
83
+ * @param {() => Promise} params.onReload
84
+ * @param {({ injection }) => Object} params.onInject
85
+ * @param {({ step, page }) => Promise<Object>} params.onStep
86
+ * @param {(message: string) => void} [params.log]
87
+ * @param {number} [params.pollInterval] Ms between polls.
88
+ */
89
+ function createDevTabClient({
90
+ apiBase,
91
+ device,
92
+ fetch,
93
+ getLowdefy,
94
+ log = () => undefined,
95
+ onInject,
96
+ onReload,
97
+ onStep,
98
+ pollInterval = 1000,
99
+ streamFetch,
100
+ }) {
101
+ let stopped = false;
102
+ let tabId = null;
103
+ let postedPage = null;
104
+ let streamAbort = null;
105
+ let pageTimer = null;
106
+
107
+ async function post(path, body) {
108
+ try {
109
+ await fetch(`${apiBase}/api/dev-inspect${path}`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify(body),
113
+ });
114
+ } catch (error) {
115
+ // A failed answer leaves the agent's request to time out on the server.
116
+ log(`Dev tab: could not reach the dev server (${error.message}).`);
117
+ }
118
+ }
119
+
120
+ function postShownPage() {
121
+ const page = getShownPage(getLowdefy());
122
+ if (tabId === null || page === null) {
123
+ return;
124
+ }
125
+ const key = `${tabId}|${page.instanceKey}`;
126
+ if (key === postedPage) {
127
+ return;
128
+ }
129
+ postedPage = key;
130
+ post('/page', { tabId, ...page });
131
+ }
132
+
133
+ async function respond({ event, request }) {
134
+ const lowdefy = getLowdefy();
135
+ const page = requestedPage({ request, shownPage: getShownPage(lowdefy) });
136
+ if (event === 'inspect-request') {
137
+ return answerInspect({ lowdefy, page });
138
+ }
139
+ if (event === 'eval-request') {
140
+ return answerEval({ expression: request.expression, lowdefy, page });
141
+ }
142
+ if (event === 'load-state-request') {
143
+ return answerLoadState({ lowdefy, page, state: request.state, verify: request.verify });
144
+ }
145
+ if (event === 'inject') {
146
+ return onInject({ injection: request.inject });
147
+ }
148
+ return onStep({ step: request.step, page });
149
+ }
150
+
151
+ async function answer({ event, data }) {
152
+ let requestId;
153
+ try {
154
+ const request = JSON.parse(data);
155
+ requestId = request.requestId;
156
+ const result = await respond({ event, request });
157
+ await post('', { requestId, result });
158
+ } catch (error) {
159
+ await post('', { requestId, result: { error: error.message } });
160
+ }
161
+ }
162
+
163
+ async function handleEvent({ event, data }) {
164
+ switch (event) {
165
+ case 'tab':
166
+ tabId = JSON.parse(data).tabId;
167
+ postedPage = null;
168
+ log(`Dev tab: joined the dev server as tab ${tabId}.`);
169
+ postShownPage();
170
+ return;
171
+ case 'reload':
172
+ log('Dev tab: the dev server rebuilt the app; reloading.');
173
+ await onReload();
174
+ return;
175
+ case 'inspect-request':
176
+ case 'eval-request':
177
+ case 'load-state-request':
178
+ case 'step-request':
179
+ case 'inject':
180
+ await answer({ event, data });
181
+ return;
182
+ default:
183
+ // ping
184
+ return;
185
+ }
186
+ }
187
+
188
+ function dispatch(message) {
189
+ handleEvent(message).catch((error) =>
190
+ log(`Dev tab: ${message.event} failed: ${error.message}`)
191
+ );
192
+ }
193
+
194
+ // Resolves 'ended' when an open stream ends (the dev server restarted), 'unreachable' when the
195
+ // server did not answer, and 'unsupported' when no event arrived over a stream that opened.
196
+ async function runStream() {
197
+ let received = false;
198
+ const controller = new AbortController();
199
+ streamAbort = controller;
200
+ const firstEventTimer = setTimeout(() => {
201
+ if (!received) controller.abort();
202
+ }, FIRST_EVENT_MS);
203
+ try {
204
+ const res = await streamFetch(`${apiBase}/api/reload?${deviceQuery(device)}`, {
205
+ headers: { Accept: 'text/event-stream' },
206
+ signal: controller.signal,
207
+ });
208
+ if (!res.ok) {
209
+ return 'unreachable';
210
+ }
211
+ if (type.isNone(res.body?.getReader)) {
212
+ return 'unsupported';
213
+ }
214
+ await readEventStream({
215
+ body: res.body,
216
+ onEvent: (message) => {
217
+ received = true;
218
+ dispatch(message);
219
+ },
220
+ });
221
+ return 'ended';
222
+ } catch (error) {
223
+ if (received) return 'ended';
224
+ return controller.signal.aborted ? 'unsupported' : 'unreachable';
225
+ } finally {
226
+ clearTimeout(firstEventTimer);
227
+ }
228
+ }
229
+
230
+ async function runPolls() {
231
+ while (!stopped) {
232
+ const tabQuery = tabId === null ? '' : `&tabId=${encodeURIComponent(tabId)}`;
233
+ try {
234
+ const res = await fetch(
235
+ `${apiBase}/api/reload?transport=poll&${deviceQuery(device)}${tabQuery}`,
236
+ { headers: { [devPassiveHeader]: '1' } }
237
+ );
238
+ if (res.ok) {
239
+ const { events } = await res.json();
240
+ events.forEach(dispatch);
241
+ }
242
+ } catch (error) {
243
+ // The dev server is restarting: poll again.
244
+ }
245
+ await sleep(pollInterval);
246
+ }
247
+ }
248
+
249
+ async function run() {
250
+ while (!stopped) {
251
+ const outcome = await runStream();
252
+ if (stopped) return;
253
+ if (outcome === 'unsupported') {
254
+ log('Dev tab: the event stream could not be read; polling every second.');
255
+ tabId = null;
256
+ await runPolls();
257
+ return;
258
+ }
259
+ await sleep(RECONNECT_MS);
260
+ }
261
+ }
262
+
263
+ function start() {
264
+ pageTimer = setInterval(postShownPage, PAGE_CHECK_MS);
265
+ run();
266
+ }
267
+
268
+ function stop() {
269
+ stopped = true;
270
+ streamAbort?.abort();
271
+ clearInterval(pageTimer);
272
+ }
273
+
274
+ return { getTabId: () => tabId, start, stop };
275
+ }
276
+
277
+ export default createDevTabClient;
@@ -0,0 +1,314 @@
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
+ import { waitFor } from '@testing-library/react-native';
19
+ import { get, serializer } from '@lowdefy/helpers';
20
+
21
+ import createDevTabClient from './createDevTabClient.js';
22
+ import DEV_CLIENT_MARKER from './devClientMarker.js';
23
+ import { DEV_CLIENT_MARKER as BUNDLE_MARKER } from '../../../shellVariant.js';
24
+
25
+ // The tab channel's payloads, shared with the dev server's tests
26
+ // (server-dev src/routes/devInspect.test.mjs): a phone answers in a browser tab's shape.
27
+ const fixtures = require('../../../../servers/server-dev/test/tabChannelFixtures.json');
28
+
29
+ const apiBase = 'http://192.168.1.20:3000';
30
+
31
+ function createStream() {
32
+ const encoder = new TextEncoder();
33
+ const chunks = [];
34
+ let waiting = null;
35
+ let closed = false;
36
+ function deliver(result) {
37
+ const resolve = waiting;
38
+ waiting = null;
39
+ resolve(result);
40
+ }
41
+ return {
42
+ body: {
43
+ getReader: () => ({
44
+ read: () => {
45
+ if (chunks.length > 0) return Promise.resolve({ done: false, value: chunks.shift() });
46
+ if (closed) return Promise.resolve({ done: true });
47
+ return new Promise((resolve) => {
48
+ waiting = resolve;
49
+ });
50
+ },
51
+ }),
52
+ },
53
+ send(event, data) {
54
+ const value = encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
55
+ if (waiting) deliver({ done: false, value });
56
+ else chunks.push(value);
57
+ },
58
+ close() {
59
+ closed = true;
60
+ if (waiting) deliver({ done: true });
61
+ },
62
+ };
63
+ }
64
+
65
+ // The app-level lowdefy object with the fixture page on screen at /tickets/7.
66
+ function createLowdefy() {
67
+ const context = {
68
+ ...fixtures.pageContext,
69
+ state: { ...fixtures.pageContext.state },
70
+ pathParams: fixtures.page.pathParams,
71
+ _internal: {
72
+ parser: {
73
+ parse: ({ input }) => ({ output: get(context.state, input._state), errors: [] }),
74
+ },
75
+ State: {
76
+ set: (key, value) => {
77
+ context.state[key] = value;
78
+ },
79
+ },
80
+ update: jest.fn(),
81
+ },
82
+ };
83
+ return {
84
+ basePath: '',
85
+ pathMemory: new Map([['tickets/7', fixtures.page]]),
86
+ contexts: { [fixtures.page.instanceKey]: context },
87
+ inputs: { [fixtures.page.instanceKey]: fixtures.app.input },
88
+ lowdefyGlobal: fixtures.app.lowdefyGlobal,
89
+ user: fixtures.app.user,
90
+ _internal: {
91
+ globals: {
92
+ window: {
93
+ location: {
94
+ href: `${apiBase}/tickets/7${fixtures.app.urlQuery}`,
95
+ search: fixtures.app.urlQuery,
96
+ },
97
+ },
98
+ },
99
+ },
100
+ };
101
+ }
102
+
103
+ function setup({ device, stream = createStream(), streamFetch, pollEvents = [] } = {}) {
104
+ const fetch = jest.fn(async (url) => {
105
+ if (url.includes('transport=poll')) {
106
+ return { ok: true, json: async () => ({ tabId: 'phone', events: pollEvents.splice(0) }) };
107
+ }
108
+ return { ok: true, json: async () => ({ ok: true }) };
109
+ });
110
+ const onReload = jest.fn(async () => undefined);
111
+ const onStep = jest.fn(async ({ step }) => ({ ok: true, ran: step }));
112
+ const onInject = jest.fn(({ injection }) => ({ ok: true, kind: injection.kind, pending: 1 }));
113
+ const client = createDevTabClient({
114
+ apiBase,
115
+ device,
116
+ fetch,
117
+ getLowdefy: createLowdefy,
118
+ onInject,
119
+ onReload,
120
+ onStep,
121
+ pollInterval: 10,
122
+ streamFetch: streamFetch ?? jest.fn(async () => ({ ok: true, body: stream.body })),
123
+ });
124
+ client.start();
125
+ return { client, fetch, onInject, onReload, onStep, stream };
126
+ }
127
+
128
+ function posts(fetch, path) {
129
+ return fetch.mock.calls
130
+ .filter(([url, init]) => url === `${apiBase}/api/dev-inspect${path}` && init.method === 'POST')
131
+ .map(([, init]) => JSON.parse(init.body));
132
+ }
133
+
134
+ let running;
135
+ afterEach(() => {
136
+ running?.client.stop();
137
+ running?.stream.close();
138
+ });
139
+
140
+ test('the marker a release bundle is checked for is the one the client sends', () => {
141
+ expect(DEV_CLIENT_MARKER).toBe(BUNDLE_MARKER);
142
+ });
143
+
144
+ test('the shell registers as a tab on the reload stream and posts the page it shows', async () => {
145
+ running = setup();
146
+ const { client, fetch, stream } = running;
147
+ expect(posts(fetch, '/page')).toEqual([]);
148
+ stream.send('tab', { tabId: 'phone' });
149
+ await waitFor(() =>
150
+ expect(posts(fetch, '/page')).toEqual([{ tabId: 'phone', ...fixtures.page }])
151
+ );
152
+ expect(client.getTabId()).toBe('phone');
153
+ });
154
+
155
+ test('the stream is opened on /api/reload with the dev client marker', async () => {
156
+ const stream = createStream();
157
+ const streamFetch = jest.fn(async () => ({ ok: true, body: stream.body }));
158
+ running = setup({ stream, streamFetch });
159
+ await waitFor(() => expect(streamFetch).toHaveBeenCalled());
160
+ const [url, init] = streamFetch.mock.calls[0];
161
+ expect(url).toBe(`${apiBase}/api/reload?client=${DEV_CLIENT_MARKER}`);
162
+ expect(init.headers).toEqual({ Accept: 'text/event-stream' });
163
+ });
164
+
165
+ test('the stream names the phone platform and app id for the device tools', async () => {
166
+ const stream = createStream();
167
+ const streamFetch = jest.fn(async () => ({ ok: true, body: stream.body }));
168
+ running = setup({ device: { platform: 'ios', app: 'com.lowdefy.dev' }, stream, streamFetch });
169
+ await waitFor(() => expect(streamFetch).toHaveBeenCalled());
170
+ expect(streamFetch.mock.calls[0][0]).toBe(
171
+ `${apiBase}/api/reload?client=${DEV_CLIENT_MARKER}&platform=ios&app=com.lowdefy.dev`
172
+ );
173
+ });
174
+
175
+ test('a load-state-request sets the checkpoint state on the page and answers the keys it verified', async () => {
176
+ running = setup();
177
+ running.stream.send(fixtures.loadStateRequest.event, {
178
+ requestId: 'r-load',
179
+ ...fixtures.page,
180
+ ...fixtures.loadStateRequest.payload,
181
+ });
182
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
183
+ expect(posts(running.fetch, '')[0]).toEqual({
184
+ requestId: 'r-load',
185
+ result: fixtures.loadStateResult,
186
+ });
187
+ });
188
+
189
+ test('a reload event reloads the app', async () => {
190
+ running = setup();
191
+ running.stream.send('reload', {});
192
+ await waitFor(() => expect(running.onReload).toHaveBeenCalledTimes(1));
193
+ });
194
+
195
+ test('an inspect-request is answered with the payload a browser tab posts', async () => {
196
+ running = setup();
197
+ running.stream.send(fixtures.inspectRequest.event, {
198
+ requestId: 'r-inspect',
199
+ ...fixtures.page,
200
+ ...fixtures.inspectRequest.payload,
201
+ });
202
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
203
+ const [answer] = posts(running.fetch, '');
204
+ expect(answer.requestId).toBe('r-inspect');
205
+ expect(serializer.deserializeFromString(answer.result)).toEqual(fixtures.inspectResult);
206
+ });
207
+
208
+ test('an eval-request is answered with the payload a browser tab posts', async () => {
209
+ running = setup();
210
+ running.stream.send(fixtures.evalRequest.event, {
211
+ requestId: 'r-eval',
212
+ ...fixtures.page,
213
+ ...fixtures.evalRequest.payload,
214
+ });
215
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
216
+ const [answer] = posts(running.fetch, '');
217
+ expect(answer.requestId).toBe('r-eval');
218
+ expect({
219
+ value: serializer.deserializeFromString(answer.result.value),
220
+ errors: answer.result.errors,
221
+ }).toEqual(fixtures.evalResult);
222
+ });
223
+
224
+ test('a request for a page with no live context is answered with an error', async () => {
225
+ running = setup();
226
+ running.stream.send('inspect-request', {
227
+ requestId: 'r-gone',
228
+ pageId: 'home',
229
+ pathParams: {},
230
+ instanceKey: 'page:home',
231
+ });
232
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
233
+ expect(posts(running.fetch, '')[0]).toEqual({
234
+ requestId: 'r-gone',
235
+ result: { error: 'No live context for page "home".' },
236
+ });
237
+ });
238
+
239
+ test('a step-request runs the journey step and posts its result', async () => {
240
+ running = setup();
241
+ running.stream.send('step-request', {
242
+ requestId: 'r-step',
243
+ ...fixtures.page,
244
+ step: { click: 'save' },
245
+ });
246
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
247
+ expect(running.onStep).toHaveBeenCalledWith({
248
+ step: { click: 'save' },
249
+ page: fixtures.page,
250
+ });
251
+ expect(posts(running.fetch, '')[0]).toEqual({
252
+ requestId: 'r-step',
253
+ result: { ok: true, ran: { click: 'save' } },
254
+ });
255
+ });
256
+
257
+ test('a failed step is answered with its error', async () => {
258
+ running = setup();
259
+ running.onStep.mockImplementation(async () => {
260
+ throw new Error('Block "save" is not on page "ticket".');
261
+ });
262
+ running.stream.send('step-request', { requestId: 'r-fail', step: { click: 'save' } });
263
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
264
+ expect(posts(running.fetch, '')[0].result).toEqual({
265
+ error: 'Block "save" is not on page "ticket".',
266
+ });
267
+ });
268
+
269
+ test('an inject request fakes the next device result and posts what was queued', async () => {
270
+ running = setup();
271
+ running.stream.send('inject', {
272
+ requestId: 'r-inject',
273
+ inject: { kind: 'scan', value: { data: 'JOB-42', type: 'qr' } },
274
+ });
275
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
276
+ expect(running.onInject).toHaveBeenCalledWith({
277
+ injection: { kind: 'scan', value: { data: 'JOB-42', type: 'qr' } },
278
+ });
279
+ expect(posts(running.fetch, '')[0]).toEqual({
280
+ requestId: 'r-inject',
281
+ result: { ok: true, kind: 'scan', pending: 1 },
282
+ });
283
+ });
284
+
285
+ test('a refused injection is answered with why', async () => {
286
+ running = setup();
287
+ running.onInject.mockImplementation(() => {
288
+ throw new Error('A scan injection needs { data }. Received "JOB-42".');
289
+ });
290
+ running.stream.send('inject', { requestId: 'r-bad', inject: { kind: 'scan', value: 'JOB-42' } });
291
+ await waitFor(() => expect(posts(running.fetch, '')).toHaveLength(1));
292
+ expect(posts(running.fetch, '')[0].result).toEqual({
293
+ error: 'A scan injection needs { data }. Received "JOB-42".',
294
+ });
295
+ });
296
+
297
+ test('without a readable stream the shell polls every interval, as a passive caller', async () => {
298
+ const pollEvents = [
299
+ { event: 'tab', data: JSON.stringify({ tabId: 'phone' }) },
300
+ { event: 'reload', data: '{}' },
301
+ ];
302
+ running = setup({
303
+ pollEvents,
304
+ streamFetch: jest.fn(async () => ({ ok: true, body: null })),
305
+ });
306
+ await waitFor(() => expect(running.onReload).toHaveBeenCalledTimes(1));
307
+ await waitFor(() => expect(posts(running.fetch, '/page')).toHaveLength(1));
308
+ const polls = running.fetch.mock.calls.filter(([url]) => url.includes('transport=poll'));
309
+ expect(polls[0][0]).toBe(`${apiBase}/api/reload?transport=poll&client=${DEV_CLIENT_MARKER}`);
310
+ expect(polls[0][1].headers).toEqual({ 'x-lowdefy-passive': '1' });
311
+ await waitFor(() =>
312
+ expect(running.fetch.mock.calls.some(([url]) => url.endsWith('&tabId=phone'))).toBe(true)
313
+ );
314
+ });
@@ -0,0 +1,22 @@
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
+ // Sent with every connection the dev-tab client opens, so the dev server knows a shell's tab from a
18
+ // browser's, and so a release bundle that holds it fails scripts/checkReleaseBundle.mjs. Keep it in
19
+ // step with shellVariant.js.
20
+ const DEV_CLIENT_MARKER = 'lowdefy-dev-tab-client';
21
+
22
+ export default DEV_CLIENT_MARKER;
@@ -0,0 +1,23 @@
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 DEV_CLIENT_MARKER from './devClientMarker.js';
18
+
19
+ const { DEV_CLIENT_MARKER: RELEASE_CHECK_MARKER } = require('../../../shellVariant.js');
20
+
21
+ test('the dev-tab client marker matches the one the release bundle check looks for', () => {
22
+ expect(DEV_CLIENT_MARKER).toBe(RELEASE_CHECK_MARKER);
23
+ });
@@ -0,0 +1,43 @@
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
+ // `page "ticket"`, or `page "ticket" (tickets/s/1)` for an instance of a page with placeholders,
20
+ // whose instance key holds the path its values build. Worded as the browser tab words it. A
21
+ // screen's key ends in `@{instanceId}`, its route instance, which is left out.
22
+ function describePage({ pageId, instanceKey }) {
23
+ const pageKey = instanceKey.split('@')[0];
24
+ const hashIndex = pageKey.indexOf('#');
25
+ if (hashIndex === -1) {
26
+ return `page "${pageId}"`;
27
+ }
28
+ return `page "${pageId}" (${pageKey.slice(hashIndex + 1)})`;
29
+ }
30
+
31
+ // The engine context of a page instance: `{ context }`, or `{ error }` as a browser tab answers it.
32
+ function findPageContext({ lowdefy, page }) {
33
+ if (type.isNone(page)) {
34
+ return { error: 'No page has rendered in this tab yet.' };
35
+ }
36
+ const context = lowdefy?.contexts?.[page.instanceKey];
37
+ if (type.isNone(context)) {
38
+ return { error: `No live context for ${describePage(page)}.` };
39
+ }
40
+ return { context };
41
+ }
42
+
43
+ export default findPageContext;