@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.
- package/LICENSE +201 -0
- package/README.md +308 -0
- package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
- package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
- package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
- package/app/+native-intent.js +30 -0
- package/app/_layout.js +33 -0
- package/app.config.ts +168 -0
- package/applyShellPlugins.js +43 -0
- package/authCallbackIntentFilters.js +46 -0
- package/babel.config.js +25 -0
- package/checkShellConfig.js +145 -0
- package/checkShellPlugins.js +74 -0
- package/getPrivacyManifest.js +39 -0
- package/metro.config.js +73 -0
- package/native-fingerprint.json +12 -0
- package/package.json +130 -0
- package/profiles.js +274 -0
- package/readShellConfig.js +36 -0
- package/resolveShellPlugins.js +109 -0
- package/runtimeVersion.js +105 -0
- package/scripts/checkReleaseBundle.mjs +358 -0
- package/scripts/createDayjsLocales.mjs +67 -0
- package/scripts/createShellAssets.mjs +54 -0
- package/scripts/createShellVersion.mjs +83 -0
- package/scripts/createTypeManifest.mjs +205 -0
- package/shell.dev.json +11 -0
- package/shellVariant.js +111 -0
- package/src/ShellContext.js +32 -0
- package/src/actions/CopyToClipboard.js +29 -0
- package/src/actions/CopyToClipboard.test.js +33 -0
- package/src/actions/GeolocationCurrentPosition.js +55 -0
- package/src/actions/GeolocationCurrentPosition.test.js +66 -0
- package/src/actions/GetLocalStorage.js +36 -0
- package/src/actions/RemoveLocalStorage.js +31 -0
- package/src/actions/ScrollTo.js +66 -0
- package/src/actions/ScrollTo.test.js +83 -0
- package/src/actions/SetFocus.js +33 -0
- package/src/actions/SetFocus.test.js +49 -0
- package/src/actions/SetLocalStorage.js +34 -0
- package/src/actions/localStorage.test.js +68 -0
- package/src/actions/measureInScrollView.js +36 -0
- package/src/actions/nativeActions.js +41 -0
- package/src/auth/AuthButton.js +44 -0
- package/src/auth/AuthGate.js +57 -0
- package/src/auth/AuthLink.js +43 -0
- package/src/auth/LockScreen.js +67 -0
- package/src/auth/LockScreen.test.js +37 -0
- package/src/auth/SignInScreen.js +256 -0
- package/src/auth/SignInScreen.test.js +182 -0
- package/src/auth/createAppAuth.js +306 -0
- package/src/auth/createAppAuth.test.js +414 -0
- package/src/auth/createAuthApi.js +99 -0
- package/src/auth/createAuthApi.test.js +135 -0
- package/src/auth/createCoreAuth.js +96 -0
- package/src/auth/createCoreAuth.test.js +188 -0
- package/src/auth/createSessionStores.js +45 -0
- package/src/auth/createTokenStore.js +103 -0
- package/src/auth/createTokenStore.test.js +116 -0
- package/src/auth/getAuthSettings.js +24 -0
- package/src/auth/getAuthView.js +39 -0
- package/src/auth/getAuthView.test.js +52 -0
- package/src/auth/getSheetReturnUrl.js +35 -0
- package/src/auth/getTokenStoreOptions.js +46 -0
- package/src/auth/isBackgroundShell.js +26 -0
- package/src/auth/isSheetReturnLink.js +35 -0
- package/src/auth/parseQuery.js +39 -0
- package/src/auth/purgeAppData.js +45 -0
- package/src/auth/shouldLock.js +26 -0
- package/src/auth/signInWithSheet.js +97 -0
- package/src/auth/signInWithSheet.test.js +205 -0
- package/src/auth/styles.js +78 -0
- package/src/auth/unregisterPushToken.js +21 -0
- package/src/auth/usesBiometricUnlock.js +23 -0
- package/src/auth/watchSession.js +47 -0
- package/src/auth/withoutCookies.js +26 -0
- package/src/cache/createSqliteCacheStore.js +113 -0
- package/src/cache/createSqliteCacheStore.test.js +93 -0
- package/src/cache/getCacheKey.js +25 -0
- package/src/cache/getLastCacheKeyName.js +22 -0
- package/src/components/AppProvider.js +90 -0
- package/src/components/Frame.js +116 -0
- package/src/components/Frame.test.js +101 -0
- package/src/components/LoadingScreen.js +35 -0
- package/src/components/MenuButton.js +108 -0
- package/src/components/MenuButton.test.js +51 -0
- package/src/components/MessageScreen.js +51 -0
- package/src/components/NavigationTheme.js +33 -0
- package/src/components/NoConnectionBanner.js +51 -0
- package/src/components/NoConnectionBanner.test.js +73 -0
- package/src/components/OtherPageScreen.js +56 -0
- package/src/components/PageScreen.js +244 -0
- package/src/components/PageScreen.test.js +492 -0
- package/src/components/ShellRoot.js +34 -0
- package/src/components/ShellRoot.test.js +62 -0
- package/src/components/ShellThemeProvider.js +45 -0
- package/src/components/SwitchToTab.js +36 -0
- package/src/components/SwitchToTab.test.js +63 -0
- package/src/components/TabIconImages.js +72 -0
- package/src/components/UpdateScreen.js +36 -0
- package/src/components/getNavigationTheme.js +44 -0
- package/src/components/getNavigationTheme.test.js +41 -0
- package/src/components/styles.js +50 -0
- package/src/components/useSurfaceTokens.js +47 -0
- package/src/config/getAppApiBase.js +31 -0
- package/src/config/getShellConfig.js +35 -0
- package/src/config/getShellConfig.test.js +80 -0
- package/src/createShellApp.js +105 -0
- package/src/dev/DevShell.js +228 -0
- package/src/dev/DevShell.test.js +233 -0
- package/src/dev/connect/ConfirmSheet.js +71 -0
- package/src/dev/connect/ConnectScreen.js +117 -0
- package/src/dev/connect/QrScanner.js +89 -0
- package/src/dev/connection/createDevShellConfig.js +47 -0
- package/src/dev/connection/createDevTransport.js +51 -0
- package/src/dev/connection/createDevTransport.test.js +87 -0
- package/src/dev/connection/createServerList.js +48 -0
- package/src/dev/connection/isLoopbackHost.js +25 -0
- package/src/dev/connection/pairedShellApp.test.js +104 -0
- package/src/dev/connection/parseDevLink.js +66 -0
- package/src/dev/connection/parseDevLink.test.js +97 -0
- package/src/dev/connection/parseQuery.js +33 -0
- package/src/dev/connection/parseServerInput.js +40 -0
- package/src/dev/connection/parseServerUrl.js +45 -0
- package/src/dev/devStyles.js +97 -0
- package/src/dev/index.js +21 -0
- package/src/dev/inject/createDeviceInjections.js +185 -0
- package/src/dev/inject/createDeviceInjections.test.js +208 -0
- package/src/dev/inject/createOfflineFetch.js +35 -0
- package/src/dev/inject/createOfflineFetch.test.js +68 -0
- package/src/dev/menu/BlockGallery.js +33 -0
- package/src/dev/menu/DevMenu.js +121 -0
- package/src/dev/menu/createLogBuffer.js +83 -0
- package/src/dev/menu/watchShake.js +52 -0
- package/src/dev/menu/watchShake.test.js +61 -0
- package/src/dev/recorder/createNativeRecorder.js +204 -0
- package/src/dev/recorder/createNativeRecorder.test.js +307 -0
- package/src/dev/redirectDevLink.js +30 -0
- package/src/dev/redirectDevLink.test.js +49 -0
- package/src/dev/steps/createStepRunner.js +184 -0
- package/src/dev/steps/createStepRunner.test.js +262 -0
- package/src/dev/steps/findBlock.js +78 -0
- package/src/dev/steps/isContextSettled.js +37 -0
- package/src/dev/steps/waitForSettle.js +39 -0
- package/src/dev/steps/waitUntil.js +40 -0
- package/src/dev/tab/DevTabClient.js +97 -0
- package/src/dev/tab/answerEval.js +39 -0
- package/src/dev/tab/answerInspect.js +45 -0
- package/src/dev/tab/answerLoadState.js +50 -0
- package/src/dev/tab/createDevTabClient.js +277 -0
- package/src/dev/tab/createDevTabClient.test.js +314 -0
- package/src/dev/tab/devClientMarker.js +22 -0
- package/src/dev/tab/devClientMarker.test.js +23 -0
- package/src/dev/tab/findPageContext.js +43 -0
- package/src/dev/tab/getShownPage.js +42 -0
- package/src/dev/tab/readEventStream.js +68 -0
- package/src/dev/tab/reloadApp.js +42 -0
- package/src/dev/tab/reloadApp.test.js +115 -0
- package/src/device/injected.js +60 -0
- package/src/device/injected.test.js +39 -0
- package/src/host/createDeviceStore.js +32 -0
- package/src/host/createMemoryStore.js +32 -0
- package/src/host/createNetworkStatus.js +94 -0
- package/src/host/createNetworkStatus.test.js +94 -0
- package/src/host/createRouterAdapter.js +33 -0
- package/src/host/createShellFetch.js +60 -0
- package/src/host/createShellFetch.test.js +150 -0
- package/src/host/createShellGlobals.js +62 -0
- package/src/host/createShellHost.js +73 -0
- package/src/host/createShellHost.test.js +36 -0
- package/src/host/getOrigin.js +26 -0
- package/src/host/isOnline.js +22 -0
- package/src/host/networkStatus.js +24 -0
- package/src/host/withShellHeader.js +25 -0
- package/src/host/withShellHeader.test.js +28 -0
- package/src/js/evaluateJsEntries.js +43 -0
- package/src/js/evaluateJsEntries.test.js +48 -0
- package/src/locale/createShellLocale.js +112 -0
- package/src/locale/createShellLocale.test.js +74 -0
- package/src/locale/dayjsLocales.js +165 -0
- package/src/locale/getDeviceLocales.js +23 -0
- package/src/locale/loadDayjsLocale.js +32 -0
- package/src/locale/resolveActiveLocale.js +46 -0
- package/src/locale/resolveActiveLocale.test.js +40 -0
- package/src/navigation/SlotContext.js +24 -0
- package/src/navigation/frameSlots.js +27 -0
- package/src/navigation/getFrame.js +30 -0
- package/src/navigation/getInitialScreenOptions.js +63 -0
- package/src/navigation/getInitialScreenOptions.test.js +66 -0
- package/src/navigation/getPresentation.js +28 -0
- package/src/navigation/getSlotRootPath.js +27 -0
- package/src/navigation/getStackOptions.js +185 -0
- package/src/navigation/getTabHref.js +45 -0
- package/src/navigation/getTabIcons.js +43 -0
- package/src/navigation/isPageUnderHeader.js +46 -0
- package/src/navigation/navigation.test.js +342 -0
- package/src/navigation/toRouterHref.js +29 -0
- package/src/plugins/appTypes.js +28 -0
- package/src/plugins/prefixTypes.js +28 -0
- package/src/plugins/shellPlugins.json +14 -0
- package/src/plugins/typeManifest.js +649 -0
- package/src/plugins/types.js +61 -0
- package/src/plugins/types.test.js +173 -0
- package/src/polyfills/installGetRandomValues.js +28 -0
- package/src/session/createAppSession.js +318 -0
- package/src/session/createAppSession.test.js +359 -0
- package/src/session/getPageView.js +59 -0
- package/src/session/getPageView.test.js +137 -0
- package/src/sync/encodePagePath.js +27 -0
- package/src/sync/fetchPage.js +49 -0
- package/src/sync/fetchRoot.js +38 -0
- package/src/sync/fetchUser.js +28 -0
- package/src/sync/runLimited.js +36 -0
- package/src/sync/toRootConfig.js +35 -0
- package/src/test/createFakeNetwork.js +40 -0
- package/src/test/createFakeSecureStore.js +40 -0
- package/src/test/createFakeServer.js +98 -0
- package/src/test/createMemoryCacheStore.js +59 -0
- package/src/test/expoImage.js +38 -0
- package/src/test/expoSpeechRecognition.js +31 -0
- package/src/test/expoUi.js +55 -0
- package/src/test/expoUiBottomSheet.js +64 -0
- package/src/test/expoUiDateTimePicker.js +26 -0
- package/src/test/expoUiMenu.js +46 -0
- package/src/test/expoUiSegmentedControl.js +47 -0
- package/src/test/expoUiSwiftUi.js +62 -0
- package/src/test/expoUiSwiftUiModifiers.js +23 -0
- package/src/test/expoWebBrowser.js +23 -0
- package/src/test/fixtures.js +98 -0
- package/src/test/renderPageScreen.js +109 -0
- package/src/types/checkTypeSet.js +39 -0
- package/src/types/collectPageTypes.js +49 -0
- package/src/types/collectPageTypes.test.js +90 -0
- package/src/types/findMissingPageTypes.js +40 -0
- package/src/version/compareVersions.js +38 -0
- package/src/version/isShellTooOld.js +30 -0
- package/src/version/shellVersion.js +21 -0
- package/src/version/shellVersion.test.js +32 -0
- package/withExcludedNativeModules.js +93 -0
|
@@ -0,0 +1,307 @@
|
|
|
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, TextInput as RNTextInput, 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
|
+
import validateTraceRecord from '@lowdefy/node-utils/journeyTrace/validateTraceRecord.js';
|
|
25
|
+
|
|
26
|
+
import createMemoryStore from '../../host/createMemoryStore.js';
|
|
27
|
+
import createNativeRecorder from './createNativeRecorder.js';
|
|
28
|
+
|
|
29
|
+
// The records the dev server's recording validation runs over (server-dev
|
|
30
|
+
// src/routes/devRecording.test.mjs): what this recorder writes for a click, a fill and a select.
|
|
31
|
+
const fixture = require('../../../../servers/server-dev/test/nativeRecordingFixture.json');
|
|
32
|
+
|
|
33
|
+
// Stand-ins for native blocks that call their methods as blocks-native's do.
|
|
34
|
+
function Box({ content }) {
|
|
35
|
+
return <View>{content.content?.()}</View>;
|
|
36
|
+
}
|
|
37
|
+
Box.meta = { category: 'container', slots: { content: 'Child blocks.' } };
|
|
38
|
+
|
|
39
|
+
function Button({ blockId, methods, properties }) {
|
|
40
|
+
return (
|
|
41
|
+
<Pressable
|
|
42
|
+
accessibilityRole="button"
|
|
43
|
+
onPress={() => methods.triggerEvent({ name: 'onClick' })}
|
|
44
|
+
testID={`${blockId}-press`}
|
|
45
|
+
>
|
|
46
|
+
<Text>{properties.title}</Text>
|
|
47
|
+
</Pressable>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
Button.meta = { category: 'display' };
|
|
51
|
+
|
|
52
|
+
function TextInput({ blockId, methods, properties, value }) {
|
|
53
|
+
return (
|
|
54
|
+
<RNTextInput
|
|
55
|
+
onChangeText={(text) => {
|
|
56
|
+
methods.setValue(text);
|
|
57
|
+
methods.triggerEvent({ name: 'onChange', event: { value: text } });
|
|
58
|
+
}}
|
|
59
|
+
onSubmitEditing={() => methods.triggerEvent({ name: 'onPressEnter' })}
|
|
60
|
+
secureTextEntry={properties.type === 'password'}
|
|
61
|
+
testID={`${blockId}_input`}
|
|
62
|
+
value={value ?? ''}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
TextInput.meta = { category: 'input', valueType: 'string' };
|
|
67
|
+
|
|
68
|
+
function Selector({ blockId, methods, properties }) {
|
|
69
|
+
return (
|
|
70
|
+
<View>
|
|
71
|
+
{properties.options.map((option) => (
|
|
72
|
+
<Pressable
|
|
73
|
+
key={option.value}
|
|
74
|
+
onPress={() => {
|
|
75
|
+
methods.setValue(option.value);
|
|
76
|
+
methods.triggerEvent({ name: 'onChange', event: { value: option.value } });
|
|
77
|
+
}}
|
|
78
|
+
testID={`${blockId}-${option.value}`}
|
|
79
|
+
>
|
|
80
|
+
<Text>{option.label}</Text>
|
|
81
|
+
</Pressable>
|
|
82
|
+
))}
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
Selector.meta = { category: 'input', valueType: 'any' };
|
|
87
|
+
|
|
88
|
+
const types = {
|
|
89
|
+
actions: { SetState },
|
|
90
|
+
blocks: { Box, Button, Selector, TextInput },
|
|
91
|
+
operators: { _state },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function block({ id, type, events, ...rest }) {
|
|
95
|
+
const built = { id: `block:ticket:${id}:0`, blockId: id, type, ...rest };
|
|
96
|
+
if (events) {
|
|
97
|
+
built.events = Object.fromEntries(
|
|
98
|
+
Object.entries(events).map(([name, actions]) => [name, { try: actions, catch: [] }])
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return built;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pageConfig = {
|
|
105
|
+
id: 'page:ticket',
|
|
106
|
+
pageId: 'ticket',
|
|
107
|
+
blockId: 'ticket',
|
|
108
|
+
type: 'Box',
|
|
109
|
+
path: 'ticket',
|
|
110
|
+
linkPaths: {},
|
|
111
|
+
subscriptions: [],
|
|
112
|
+
requests: [],
|
|
113
|
+
slots: {
|
|
114
|
+
content: {
|
|
115
|
+
blocks: [
|
|
116
|
+
block({
|
|
117
|
+
id: 'title',
|
|
118
|
+
type: 'TextInput',
|
|
119
|
+
events: {
|
|
120
|
+
onChange: [{ id: 'echo', type: 'SetState', params: { echo: { _state: 'title' } } }],
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
block({ id: 'secret', type: 'TextInput', properties: { type: 'password' } }),
|
|
124
|
+
block({
|
|
125
|
+
id: 'priority',
|
|
126
|
+
type: 'Selector',
|
|
127
|
+
properties: {
|
|
128
|
+
options: [
|
|
129
|
+
{ label: 'Low', value: 'low' },
|
|
130
|
+
{ label: 'High', value: 'high' },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
block({
|
|
135
|
+
id: 'save',
|
|
136
|
+
type: 'Button',
|
|
137
|
+
properties: { title: 'Save' },
|
|
138
|
+
events: {
|
|
139
|
+
onClick: [{ id: 'saved', type: 'SetState', params: { saved: true } }],
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const rootConfig = {
|
|
148
|
+
classMap: {},
|
|
149
|
+
home: { configured: true, pageId: 'ticket' },
|
|
150
|
+
lowdefyApp: { slug: 'test-app' },
|
|
151
|
+
menus: [],
|
|
152
|
+
pagePaths: { ticket: 'ticket' },
|
|
153
|
+
theme: { light: {}, dark: {} },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const apiBase = 'http://192.168.1.20:3000';
|
|
157
|
+
|
|
158
|
+
async function renderTicket() {
|
|
159
|
+
const fetch = jest.fn();
|
|
160
|
+
const host = {
|
|
161
|
+
fetch,
|
|
162
|
+
globals: createNativeGlobals({
|
|
163
|
+
assign: jest.fn(),
|
|
164
|
+
fetch,
|
|
165
|
+
getPath: () => '/ticket?from=list',
|
|
166
|
+
origin: apiBase,
|
|
167
|
+
}),
|
|
168
|
+
onBuildChanged: jest.fn(),
|
|
169
|
+
onCallerChanged: jest.fn(),
|
|
170
|
+
router: { back: jest.fn(), push: jest.fn(), replace: jest.fn() },
|
|
171
|
+
sessionStorage: createMemoryStore(),
|
|
172
|
+
storage: createMemoryStore(),
|
|
173
|
+
WebSocket: jest.fn(),
|
|
174
|
+
websocketUrl: (apiPath) => apiPath,
|
|
175
|
+
};
|
|
176
|
+
const lowdefy = {};
|
|
177
|
+
const core = { apiBase, auth: { authConfig: {} }, basePath: '', lowdefy };
|
|
178
|
+
await render(
|
|
179
|
+
<LowdefyPage
|
|
180
|
+
core={core}
|
|
181
|
+
host={host}
|
|
182
|
+
pageConfig={pageConfig}
|
|
183
|
+
rootConfig={rootConfig}
|
|
184
|
+
types={types}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
await screen.findByText('Save');
|
|
188
|
+
return lowdefy;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function startRecorder(lowdefy) {
|
|
192
|
+
const posts = [];
|
|
193
|
+
const fetch = jest.fn(async (url, init) => {
|
|
194
|
+
posts.push({ url, body: JSON.parse(init.body) });
|
|
195
|
+
return { ok: true, status: 204 };
|
|
196
|
+
});
|
|
197
|
+
const kv = createMemoryStore();
|
|
198
|
+
const recorder = createNativeRecorder({
|
|
199
|
+
apiBase,
|
|
200
|
+
device: 'ios',
|
|
201
|
+
fetch,
|
|
202
|
+
getBuild: () => 'build-1',
|
|
203
|
+
getLowdefy: () => lowdefy,
|
|
204
|
+
kv,
|
|
205
|
+
shellVersion: '7.4.0',
|
|
206
|
+
});
|
|
207
|
+
recorder.start();
|
|
208
|
+
return { kv, posts, recorder };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function recordsOf(posts) {
|
|
212
|
+
return posts.flatMap((post) => post.body.records);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Times and the session id differ on every run; the fixture keeps their order.
|
|
216
|
+
function normalise(records) {
|
|
217
|
+
const start = Date.parse('2026-10-18T09:00:00.000Z');
|
|
218
|
+
return records.map((record, index) => ({
|
|
219
|
+
...record,
|
|
220
|
+
session: '20261018T090000Z-ph0ne1',
|
|
221
|
+
t: new Date(start + index * 1000).toISOString(),
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function act$(callback) {
|
|
226
|
+
await act(async () => {
|
|
227
|
+
callback();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let running;
|
|
232
|
+
afterEach(async () => {
|
|
233
|
+
await running?.recorder.stop();
|
|
234
|
+
running = null;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a click, a fill and a select are recorded by block id, paired with the events they ran', async () => {
|
|
238
|
+
const lowdefy = await renderTicket();
|
|
239
|
+
running = startRecorder(lowdefy);
|
|
240
|
+
await act$(() => fireEvent.changeText(screen.getByTestId('title_input'), 'Pump'));
|
|
241
|
+
await act$(() => fireEvent.press(screen.getByTestId('priority-high')));
|
|
242
|
+
await act$(() => fireEvent.press(screen.getByTestId('save-press')));
|
|
243
|
+
await running.recorder.flush();
|
|
244
|
+
|
|
245
|
+
const { posts } = running;
|
|
246
|
+
expect(posts.map((post) => post.url)).toEqual([`${apiBase}/api/dev-recording`]);
|
|
247
|
+
const records = recordsOf(posts);
|
|
248
|
+
expect(records.every((record) => record.session === posts[0].body.session)).toBe(true);
|
|
249
|
+
expect(normalise(records)).toEqual(fixture.records);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('every record passes the trace record validation once the dev server stamps its source', async () => {
|
|
253
|
+
const lowdefy = await renderTicket();
|
|
254
|
+
running = startRecorder(lowdefy);
|
|
255
|
+
await act$(() => fireEvent.changeText(screen.getByTestId('title_input'), 'Pump'));
|
|
256
|
+
await act$(() => fireEvent.press(screen.getByTestId('save-press')));
|
|
257
|
+
await running.recorder.flush();
|
|
258
|
+
const records = recordsOf(running.posts);
|
|
259
|
+
expect(records.length).toBeGreaterThan(0);
|
|
260
|
+
records.forEach((record) => {
|
|
261
|
+
expect(validateTraceRecord({ record: { ...record, source: 'dev' } })).toEqual({});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('a password input is never recorded', async () => {
|
|
266
|
+
const lowdefy = await renderTicket();
|
|
267
|
+
running = startRecorder(lowdefy);
|
|
268
|
+
await act$(() => fireEvent.changeText(screen.getByTestId('secret_input'), 'hunter2'));
|
|
269
|
+
await running.recorder.flush();
|
|
270
|
+
const change = recordsOf(running.posts).find((record) => record.kind === 'change');
|
|
271
|
+
expect(change).toMatchObject({ target: { block_id: 'secret' }, value: null, redacted: true });
|
|
272
|
+
expect(JSON.stringify(recordsOf(running.posts))).not.toContain('hunter2');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('pressing Enter in an input is recorded as an Enter key on its block', async () => {
|
|
276
|
+
const lowdefy = await renderTicket();
|
|
277
|
+
running = startRecorder(lowdefy);
|
|
278
|
+
await act$(() => fireEvent(screen.getByTestId('title_input'), 'submitEditing'));
|
|
279
|
+
await running.recorder.flush();
|
|
280
|
+
const key = recordsOf(running.posts).find((record) => record.kind === 'key');
|
|
281
|
+
expect(key).toMatchObject({ key: 'Enter', target: { block_id: 'title' }, page_id: 'ticket' });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('the session is kept in the device store, so a new recorder carries it on', async () => {
|
|
285
|
+
const lowdefy = await renderTicket();
|
|
286
|
+
running = startRecorder(lowdefy);
|
|
287
|
+
await running.recorder.flush();
|
|
288
|
+
const [first] = recordsOf(running.posts);
|
|
289
|
+
const again = createNativeRecorder({
|
|
290
|
+
apiBase,
|
|
291
|
+
device: 'android',
|
|
292
|
+
fetch: jest.fn(async () => ({ ok: true })),
|
|
293
|
+
getBuild: () => null,
|
|
294
|
+
getLowdefy: () => lowdefy,
|
|
295
|
+
kv: running.kv,
|
|
296
|
+
shellVersion: '7.4.0',
|
|
297
|
+
});
|
|
298
|
+
const stored = JSON.parse(running.kv.get('lowdefy_dev_recording'));
|
|
299
|
+
expect(stored.id).toBe(first.session);
|
|
300
|
+
await again.stop();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('with no open app the recorder records nothing', async () => {
|
|
304
|
+
running = startRecorder(null);
|
|
305
|
+
await running.recorder.flush();
|
|
306
|
+
expect(running.posts).toEqual([]);
|
|
307
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
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 parseDevLink from './connection/parseDevLink.js';
|
|
18
|
+
import getShellConfig from '../config/getShellConfig.js';
|
|
19
|
+
|
|
20
|
+
// Expo Router's redirectSystemPath (app/+native-intent.js): a Lowdefy Dev link is DevShell's, not a
|
|
21
|
+
// page path. Opened with one, the app starts on its home screen; while it runs, the router leaves
|
|
22
|
+
// it alone.
|
|
23
|
+
function redirectDevLink({ initial, path }) {
|
|
24
|
+
if (parseDevLink({ link: path, scheme: getShellConfig().scheme }) === null) {
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
return initial ? '/' : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default redirectDevLink;
|
|
@@ -0,0 +1,49 @@
|
|
|
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 redirectDevLink from './redirectDevLink.js';
|
|
20
|
+
|
|
21
|
+
jest.mock('../config/getShellConfig.js', () => () => ({ scheme: 'lowdefy-dev' }));
|
|
22
|
+
|
|
23
|
+
const { getShellVariant } = require('../../shellVariant.js');
|
|
24
|
+
|
|
25
|
+
test('a Lowdefy Dev link is left to DevShell: home on launch, nothing while running', () => {
|
|
26
|
+
const link = 'lowdefy-dev://connect?url=http%3A%2F%2F192.168.1.20%3A3000&pair=k3x9q';
|
|
27
|
+
expect(redirectDevLink({ initial: true, path: link })).toBe('/');
|
|
28
|
+
expect(redirectDevLink({ initial: false, path: link })).toBeNull();
|
|
29
|
+
expect(redirectDevLink({ initial: false, path: 'lowdefy-dev://menu' })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('any other link is routed as a page path', () => {
|
|
33
|
+
expect(redirectDevLink({ initial: false, path: 'lowdefy-dev://tickets/7' })).toBe(
|
|
34
|
+
'lowdefy-dev://tickets/7'
|
|
35
|
+
);
|
|
36
|
+
expect(redirectDevLink({ initial: true, path: '/tickets/7' })).toBe('/tickets/7');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('Lowdefy Dev builds as dev and every other shell as release unless the build says', () => {
|
|
40
|
+
expect(getShellVariant({})).toBe('dev');
|
|
41
|
+
expect(getShellVariant({ LOWDEFY_SHELL_CONFIG: 'shell.json' })).toBe('release');
|
|
42
|
+
expect(getShellVariant({ LOWDEFY_SHELL_VARIANT: 'release' })).toBe('release');
|
|
43
|
+
expect(
|
|
44
|
+
getShellVariant({ LOWDEFY_SHELL_CONFIG: 'shell.json', LOWDEFY_SHELL_VARIANT: 'dev' })
|
|
45
|
+
).toBe('dev');
|
|
46
|
+
expect(() => getShellVariant({ LOWDEFY_SHELL_VARIANT: 'prod' })).toThrow(
|
|
47
|
+
'LOWDEFY_SHELL_VARIANT must be "dev" or "release". Received "prod".'
|
|
48
|
+
);
|
|
49
|
+
});
|
|
@@ -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;
|