craft-native 0.0.155 → 0.0.158
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/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +42 -8
- package/dist/ios/src/index.js +48 -13
- package/dist/ios/src/testing.d.ts +7 -1
- package/dist/ios/templates/CraftApp.swift +114 -18
- package/dist/ios/templates/CraftHybrid.swift +269 -31
- package/dist/ios/templates/CraftNativeActions.swift +57 -3
- package/dist/ios/templates/CraftNativeScreen.swift +98 -22
- package/dist/ios/templates/testing/DriverTests.swift.template +161 -145
- package/package.json +1 -1
|
@@ -367,7 +367,12 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
|
367
367
|
let config: CraftConfig
|
|
368
368
|
|
|
369
369
|
func makeUIViewController(context: Context) -> UINavigationController {
|
|
370
|
-
|
|
370
|
+
let screen = CraftNativeScreenController(config: config)
|
|
371
|
+
// The shell hears the first frame (the app script starts after it)
|
|
372
|
+
// and `craft.navigation.open`, which has no paths to open here but
|
|
373
|
+
// web addresses, in the in-app browser.
|
|
374
|
+
screen.hybridEvents = CraftHybrid.shared
|
|
375
|
+
return UINavigationController(rootViewController: screen)
|
|
371
376
|
}
|
|
372
377
|
|
|
373
378
|
func updateUIViewController(_ controller: UINavigationController, context: Context) {}
|
|
@@ -2086,11 +2091,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2086
2091
|
weak var hybridEvents: CraftHybridScreenEvents?
|
|
2087
2092
|
private var hybridRendered = false
|
|
2088
2093
|
private var runtimeDisposed = false
|
|
2094
|
+
/// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
|
|
2095
|
+
/// no screen, loaded once and never shown, that runs for the app's life.
|
|
2096
|
+
/// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
|
|
2097
|
+
/// `DEEP_LINK`; it can open paths but not push, pop or style a bar.
|
|
2098
|
+
let isAppScript: Bool
|
|
2089
2099
|
|
|
2090
|
-
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
2100
|
+
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], isAppScript: Bool = false) {
|
|
2091
2101
|
self.config = config
|
|
2092
2102
|
self.routeName = routeName
|
|
2093
2103
|
self.routeParams = routeParams
|
|
2104
|
+
self.isAppScript = isAppScript
|
|
2094
2105
|
super.init(nibName: nil, bundle: nil)
|
|
2095
2106
|
}
|
|
2096
2107
|
|
|
@@ -2127,6 +2138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2127
2138
|
setupJavaScript()
|
|
2128
2139
|
loadBundle()
|
|
2129
2140
|
observeNativeEvents()
|
|
2141
|
+
if isAppScript {
|
|
2142
|
+
// Its bundle has run, so its listeners are in place: the app's
|
|
2143
|
+
// events that waited for it arrive now, in order.
|
|
2144
|
+
CraftEventManager.shared.appScriptReady { [weak self] event, detail in
|
|
2145
|
+
self?.send(type: "CRAFT_EVENT", payload: ["event": event, "detail": detail])
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2130
2148
|
}
|
|
2131
2149
|
|
|
2132
2150
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
@@ -2168,7 +2186,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2168
2186
|
|
|
2169
2187
|
override func viewDidDisappear(_ animated: Bool) {
|
|
2170
2188
|
super.viewDidDisappear(animated)
|
|
2171
|
-
|
|
2189
|
+
// Gone for good only when its stack no longer holds it. UIKit's
|
|
2190
|
+
// `isMovingFromParent` is no witness: a tab's first screen covered
|
|
2191
|
+
// by a push while its own first appearance was still settling (an
|
|
2192
|
+
// app script opening the sign-in screen at launch) reports it true
|
|
2193
|
+
// while still the root of its stack, and the screen's script was
|
|
2194
|
+
// torn down under it, frozen on whatever it showed then.
|
|
2195
|
+
let inStack = navigationController?.viewControllers.contains(where: { $0 === self }) ?? false
|
|
2196
|
+
if !inStack {
|
|
2172
2197
|
disposeRuntime()
|
|
2173
2198
|
} else {
|
|
2174
2199
|
screenVisibilityChanged(false)
|
|
@@ -2280,6 +2305,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2280
2305
|
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
2281
2306
|
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
2282
2307
|
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
2308
|
+
// The app's site and build, as JSON strings: the site an all-native
|
|
2309
|
+
// app's paths and links belong to (`devServerURL`), and the version a
|
|
2310
|
+
// request or a crash report names.
|
|
2311
|
+
let info = Bundle.main.infoDictionary
|
|
2312
|
+
let appURL = Self.jsonString(config.devServerURL ?? "")
|
|
2313
|
+
let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
|
|
2314
|
+
let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
|
|
2283
2315
|
jsContext.evaluateScript("""
|
|
2284
2316
|
globalThis.__stxNativeCallback = null;
|
|
2285
2317
|
globalThis.__stxNativeBridge = {
|
|
@@ -2290,6 +2322,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2290
2322
|
capabilities: \(capabilityJSON),
|
|
2291
2323
|
initialAppState: "\(CraftNativeActions.currentAppState())",
|
|
2292
2324
|
colorScheme: "\(colorScheme)",
|
|
2325
|
+
appURL: \(appURL),
|
|
2326
|
+
appVersion: \(appVersion),
|
|
2327
|
+
appBuild: \(appBuild),
|
|
2293
2328
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
2294
2329
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
2295
2330
|
};
|
|
@@ -2350,6 +2385,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2350
2385
|
""")
|
|
2351
2386
|
}
|
|
2352
2387
|
|
|
2388
|
+
/// `value` as a JavaScript string literal (JSON), quotes and all.
|
|
2389
|
+
static func jsonString(_ value: String) -> String {
|
|
2390
|
+
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
2391
|
+
let array = String(data: data, encoding: .utf8), array.count >= 2 else { return "\"\"" }
|
|
2392
|
+
return String(array.dropFirst().dropLast())
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2353
2395
|
/// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
|
|
2354
2396
|
/// the window's appearance once there is one, else the app's setting.
|
|
2355
2397
|
private var colorScheme: String {
|
|
@@ -2379,6 +2421,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2379
2421
|
/// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
|
|
2380
2422
|
/// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
|
|
2381
2423
|
/// promise): `Application Support/craft-snapshots/<name>.json`.
|
|
2424
|
+
/// `clear(keep?)` (a promise) deletes every snapshot but the names in
|
|
2425
|
+
/// `keep`, as a sign-out does; each removal is a `SNAPSHOT_CHANGED`
|
|
2426
|
+
/// for every screen.
|
|
2382
2427
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
2383
2428
|
/// `craft.secureStorage.set` wrote.
|
|
2384
2429
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
@@ -2405,6 +2450,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2405
2450
|
done.call(withArguments: [ok])
|
|
2406
2451
|
}
|
|
2407
2452
|
}
|
|
2453
|
+
let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
|
|
2454
|
+
let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
|
|
2455
|
+
CraftSnapshots.clear(keeping: Set(names)) { ok in
|
|
2456
|
+
done.call(withArguments: [ok])
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2408
2459
|
let config = self.config
|
|
2409
2460
|
let secureGet: @convention(block) (String) -> String? = { key in
|
|
2410
2461
|
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
@@ -2419,6 +2470,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2419
2470
|
jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
|
|
2420
2471
|
jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
|
|
2421
2472
|
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
2473
|
+
jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
|
|
2422
2474
|
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
2423
2475
|
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
2424
2476
|
// `AudioContext` and the rest of Web Audio, rendered natively.
|
|
@@ -2568,6 +2620,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2568
2620
|
else reject(new Error('craft.snapshots.set could not write ' + name));
|
|
2569
2621
|
});
|
|
2570
2622
|
});
|
|
2623
|
+
},
|
|
2624
|
+
// Every snapshot but the names in `keep` (a sign-out).
|
|
2625
|
+
clear: function(keep) {
|
|
2626
|
+
var names = Array.isArray(keep) ? keep.map(String) : [];
|
|
2627
|
+
return new Promise(function(resolve, reject) {
|
|
2628
|
+
craftNativeSnapshotClear(names, function(ok) {
|
|
2629
|
+
if (ok) resolve();
|
|
2630
|
+
else reject(new Error('craft.snapshots.clear could not remove every snapshot'));
|
|
2631
|
+
});
|
|
2632
|
+
});
|
|
2571
2633
|
}
|
|
2572
2634
|
});
|
|
2573
2635
|
keep('secureStorage', {
|
|
@@ -2623,6 +2685,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2623
2685
|
/// `craft.navigation.setOptions`: this screen's bar. Every key is
|
|
2624
2686
|
/// optional and only the keys given change.
|
|
2625
2687
|
private func applyNavigationOptions(_ options: [String: Any]) {
|
|
2688
|
+
// The app script has no screen, so no bar and no tab bar to change.
|
|
2689
|
+
guard !isAppScript else { return }
|
|
2626
2690
|
if options.keys.contains("title") {
|
|
2627
2691
|
navigationItem.title = options["title"] as? String
|
|
2628
2692
|
titleFromOptions = true
|
|
@@ -2826,20 +2890,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2826
2890
|
observe(UIApplication.didBecomeActiveNotification, state: "active")
|
|
2827
2891
|
observe(UIApplication.willResignActiveNotification, state: "inactive")
|
|
2828
2892
|
observe(UIApplication.didEnterBackgroundNotification, state: "background")
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2893
|
+
// The app script has no screen for the keyboard to cover.
|
|
2894
|
+
if !isAppScript {
|
|
2895
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
2896
|
+
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
2897
|
+
object: nil,
|
|
2898
|
+
queue: .main
|
|
2899
|
+
) { [weak self] notification in
|
|
2900
|
+
self?.applyKeyboardSafeArea(notification)
|
|
2901
|
+
})
|
|
2902
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
2903
|
+
forName: UIResponder.keyboardWillHideNotification,
|
|
2904
|
+
object: nil,
|
|
2905
|
+
queue: .main
|
|
2906
|
+
) { [weak self] notification in
|
|
2907
|
+
self?.applyKeyboardSafeArea(notification, hidden: true)
|
|
2908
|
+
})
|
|
2909
|
+
}
|
|
2843
2910
|
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
2844
2911
|
forName: CraftSnapshots.changed,
|
|
2845
2912
|
object: nil,
|
|
@@ -2873,6 +2940,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2873
2940
|
sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
|
|
2874
2941
|
streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
|
|
2875
2942
|
sendAppState(CraftNativeActions.currentAppState())
|
|
2943
|
+
// Links are the screens' to show; the app script hears none.
|
|
2944
|
+
guard !isAppScript else { return }
|
|
2876
2945
|
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
2877
2946
|
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
2878
2947
|
var payload = CraftNativeActions.deepLinkData(url)
|
|
@@ -2952,7 +3021,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2952
3021
|
}
|
|
2953
3022
|
|
|
2954
3023
|
private func sendAppState(_ state: String) {
|
|
2955
|
-
|
|
3024
|
+
// A screen hears it while it is on top of its stack; the app script,
|
|
3025
|
+
// which is on no stack, always.
|
|
3026
|
+
guard isAppScript || navigationController?.topViewController === self else { return }
|
|
2956
3027
|
send(type: "APP_STATE", payload: ["state": state])
|
|
2957
3028
|
}
|
|
2958
3029
|
|
|
@@ -2972,6 +3043,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2972
3043
|
/// is up, and are logged.
|
|
2973
3044
|
private func hybridFailed(_ message: String) {
|
|
2974
3045
|
guard !hybridRendered else { return }
|
|
3046
|
+
// The app script draws nothing for anything to stand in for: logged
|
|
3047
|
+
// (by the caller) and left to run on.
|
|
3048
|
+
guard !isAppScript else { return }
|
|
2975
3049
|
hybridEvents?.nativeScreen(self, didFail: message)
|
|
2976
3050
|
}
|
|
2977
3051
|
|
|
@@ -3014,7 +3088,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3014
3088
|
])
|
|
3015
3089
|
}
|
|
3016
3090
|
case "NAVIGATE", "NAVIGATE_REPLACE":
|
|
3017
|
-
|
|
3091
|
+
// A screen's own stack; the app script has none (it opens paths).
|
|
3092
|
+
guard !isAppScript, navigationController?.topViewController === self,
|
|
3018
3093
|
let screen = payload["screen"] as? String, !screen.isEmpty,
|
|
3019
3094
|
let navigation = navigationController else { return }
|
|
3020
3095
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
@@ -3034,13 +3109,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3034
3109
|
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
3035
3110
|
hybridEvents?.nativeScreen(self, open: path)
|
|
3036
3111
|
case "NAVIGATE_BACK":
|
|
3037
|
-
if navigationController?.topViewController === self {
|
|
3112
|
+
if !isAppScript, navigationController?.topViewController === self {
|
|
3038
3113
|
navigationController?.popViewController(animated: true)
|
|
3039
3114
|
}
|
|
3040
3115
|
case "NAVIGATE_POP_TO_ROOT":
|
|
3041
3116
|
// Back to the first screen of this screen's stack in one move: a
|
|
3042
3117
|
// finished task (a saved workout) closes the way it was reached.
|
|
3043
|
-
if navigationController?.topViewController === self {
|
|
3118
|
+
if !isAppScript, navigationController?.topViewController === self {
|
|
3044
3119
|
navigationController?.popToRootViewController(animated: true)
|
|
3045
3120
|
}
|
|
3046
3121
|
case "API_REQUEST":
|
|
@@ -3181,7 +3256,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3181
3256
|
renderCommitted(document)
|
|
3182
3257
|
if !hybridRendered {
|
|
3183
3258
|
hybridRendered = true
|
|
3184
|
-
|
|
3259
|
+
// The app script's drawing is never on screen: not a first frame.
|
|
3260
|
+
if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
|
|
3185
3261
|
}
|
|
3186
3262
|
}
|
|
3187
3263
|
|
|
@@ -26,6 +26,7 @@ import XCTest
|
|
|
26
26
|
/// expectexact <label> [| secs] a button or text whose label is exactly this
|
|
27
27
|
/// absent <text> logs PASS when no element contains text
|
|
28
28
|
/// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID)
|
|
29
|
+
/// labelid <id> <text> that element's label is exactly text (a count, a status)
|
|
29
30
|
/// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
|
|
30
31
|
/// selected <tab> the tab bar's selected button is this one
|
|
31
32
|
/// tab <name> tap a tab bar button
|
|
@@ -34,6 +35,7 @@ import XCTest
|
|
|
34
35
|
/// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
|
|
35
36
|
/// unlabelled logs every hittable button without a label (FAIL if any)
|
|
36
37
|
/// dismiss [label] a system prompt's button (default Not Now: "Save Password?")
|
|
38
|
+
/// ifid <id> <command…> runs the command only when that element is there (a screen shown the first time)
|
|
37
39
|
/// log <text>
|
|
38
40
|
///
|
|
39
41
|
/// Checks log `PASS …` / `FAIL …` lines into driver.log; matrix.sh counts them.
|
|
@@ -153,153 +155,167 @@ final class DriverTests: XCTestCase {
|
|
|
153
155
|
for raw in script.split(separator: "\n") {
|
|
154
156
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
|
155
157
|
if line.isEmpty || line.hasPrefix("#") { continue }
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
case "
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
while !done && Date() < end {
|
|
296
|
-
if let button = candidates.first(where: { $0.exists && $0.isHittable }) { button.tap(); done = true }
|
|
297
|
-
else { Thread.sleep(forTimeInterval: 0.5) }
|
|
158
|
+
run(line)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func run(_ line: String) {
|
|
163
|
+
let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
|
|
164
|
+
let command = parts[0]
|
|
165
|
+
let arg = parts.count > 1 ? parts[1] : ""
|
|
166
|
+
switch command {
|
|
167
|
+
case "ifid":
|
|
168
|
+
let rest = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
169
|
+
if rest.count == 2, app.descendants(matching: .any)[rest[0]].waitForExistence(timeout: 3) { note("ifid \(rest[0]): shown"); run(rest[1]) }
|
|
170
|
+
else { note("ifid \(rest.first ?? ""): not shown") }
|
|
171
|
+
case "launch": app.launch()
|
|
172
|
+
case "activate": app.activate()
|
|
173
|
+
case "terminate": app.terminate()
|
|
174
|
+
case "home": XCUIDevice.shared.press(.home)
|
|
175
|
+
case "background":
|
|
176
|
+
XCUIDevice.shared.press(.home)
|
|
177
|
+
Thread.sleep(forTimeInterval: Double(arg) ?? 3)
|
|
178
|
+
app.activate()
|
|
179
|
+
case "wait": Thread.sleep(forTimeInterval: Double(arg) ?? 1)
|
|
180
|
+
case "net": _ = get("/\(arg)"); note("net \(arg)")
|
|
181
|
+
case "delay": _ = get("/delay?ms=\(arg)"); note("delay \(arg)ms")
|
|
182
|
+
case "exec":
|
|
183
|
+
let out = get("/exec?name=\(arg)", timeout: 180).flatMap { String(data: $0, encoding: .utf8) } ?? "no answer"
|
|
184
|
+
note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
|
|
185
|
+
case "openurl":
|
|
186
|
+
if let url = URL(string: arg) {
|
|
187
|
+
XCUIDevice.shared.system.open(url)
|
|
188
|
+
answerOpenPrompt()
|
|
189
|
+
note("openurl \(arg)")
|
|
190
|
+
}
|
|
191
|
+
case "shot":
|
|
192
|
+
let shot = XCUIScreen.main.screenshot()
|
|
193
|
+
post("/save?name=\(arg).png", shot.pngRepresentation)
|
|
194
|
+
case "tree":
|
|
195
|
+
post("/save?name=\(arg).txt", Data(app.debugDescription.utf8))
|
|
196
|
+
case "tap":
|
|
197
|
+
if let target = waitFor(arg, 10) { press(target); note("tap \(arg): ok") }
|
|
198
|
+
else { XCTFail("Element not found"); note("FAIL tap \(arg): NOT FOUND") }
|
|
199
|
+
case "tapif":
|
|
200
|
+
if let target = element(containing: arg), target.isHittable { press(target); note("tapif \(arg): tapped") }
|
|
201
|
+
else { note("tapif \(arg): not shown") }
|
|
202
|
+
case "tapexact":
|
|
203
|
+
if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
|
|
204
|
+
else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
|
|
205
|
+
case "tapid":
|
|
206
|
+
let target = app.descendants(matching: .any)[arg]
|
|
207
|
+
if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
|
|
208
|
+
else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
209
|
+
case "tapxy":
|
|
210
|
+
let xy = arg.split(separator: " ").compactMap { Double($0) }
|
|
211
|
+
if xy.count == 2 && xy.allSatisfy({ $0 >= 0 && $0 <= 1 }) {
|
|
212
|
+
app.coordinate(withNormalizedOffset: CGVector(dx: xy[0], dy: xy[1])).tap()
|
|
213
|
+
} else { XCTFail("tapxy needs two coordinates between 0 and 1") }
|
|
214
|
+
case "back":
|
|
215
|
+
let bar = app.navigationBars.buttons.firstMatch
|
|
216
|
+
if bar.exists && bar.isHittable { bar.tap(); note("back: bar button") }
|
|
217
|
+
else {
|
|
218
|
+
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
|
|
219
|
+
start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
|
|
220
|
+
note("back: edge swipe")
|
|
221
|
+
}
|
|
222
|
+
case "type": app.typeText(arg)
|
|
223
|
+
case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
|
|
224
|
+
case "typeid":
|
|
225
|
+
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
226
|
+
let target = app.descendants(matching: .any)[idParts[0]]
|
|
227
|
+
if target.waitForExistence(timeout: 10) {
|
|
228
|
+
target.tap()
|
|
229
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
230
|
+
app.typeText(idParts.count > 1 ? idParts[1] : "")
|
|
231
|
+
note("typeid \(idParts[0]): ok")
|
|
232
|
+
} else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
|
|
233
|
+
case "field":
|
|
234
|
+
let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
235
|
+
let index = Int(fieldParts[0]) ?? 0
|
|
236
|
+
let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
|
|
237
|
+
if index >= 0 && index < fields.count {
|
|
238
|
+
fields[index].tap()
|
|
239
|
+
Thread.sleep(forTimeInterval: 0.6)
|
|
240
|
+
app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
|
|
241
|
+
note("field \(index): ok")
|
|
242
|
+
} else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
|
|
243
|
+
case "expect":
|
|
244
|
+
let (text, seconds) = split(arg)
|
|
245
|
+
check(waitFor(text, seconds) != nil, "expect \(text)")
|
|
246
|
+
case "expectexact":
|
|
247
|
+
let (text, seconds) = split(arg)
|
|
248
|
+
check(poll(seconds, { exact(text) }) != nil, "expectexact \(text)")
|
|
249
|
+
case "expectid":
|
|
250
|
+
let (id, seconds) = split(arg)
|
|
251
|
+
check(app.descendants(matching: .any)[id].waitForExistence(timeout: seconds), "expectid \(id)")
|
|
252
|
+
case "labelid":
|
|
253
|
+
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
254
|
+
let target = app.descendants(matching: .any)[idParts[0]]
|
|
255
|
+
let want = idParts.count > 1 ? idParts[1] : ""
|
|
256
|
+
let seen = target.waitForExistence(timeout: 5) ? target.label : "(missing)"
|
|
257
|
+
check(seen == want, "labelid \(idParts[0]) \(want) (saw \(seen))")
|
|
258
|
+
case "absentid":
|
|
259
|
+
check(!app.descendants(matching: .any)[arg].exists, "absentid \(arg)")
|
|
260
|
+
case "absent":
|
|
261
|
+
check(element(containing: arg) == nil, "absent \(arg)")
|
|
262
|
+
case "tabbar":
|
|
263
|
+
let want = arg != "hidden"
|
|
264
|
+
let end = Date().addingTimeInterval(5)
|
|
265
|
+
var shown = tabBarShown()
|
|
266
|
+
while shown != want && Date() < end { Thread.sleep(forTimeInterval: 0.5); shown = tabBarShown() }
|
|
267
|
+
check(shown == want, "tabbar \(arg)")
|
|
268
|
+
case "selected":
|
|
269
|
+
check(poll(5, { tabButton(arg).flatMap { $0.isSelected ? $0 : nil } }) != nil, "selected \(arg)")
|
|
270
|
+
case "tab":
|
|
271
|
+
if let button = poll(10, { tabButton(arg) }) { button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap(); note("tab \(arg): ok") }
|
|
272
|
+
else { XCTFail("Element not found"); note("FAIL tab \(arg): NOT FOUND") }
|
|
273
|
+
case "scrollto":
|
|
274
|
+
var found = element(containing: arg)
|
|
275
|
+
var swipes = 0
|
|
276
|
+
while (found == nil || !(found!.isHittable)) && swipes < 8 {
|
|
277
|
+
app.swipeUp()
|
|
278
|
+
swipes += 1
|
|
279
|
+
found = element(containing: arg)
|
|
280
|
+
}
|
|
281
|
+
check(found != nil && found!.isHittable, "scrollto \(arg)")
|
|
282
|
+
case "swipe":
|
|
283
|
+
switch arg {
|
|
284
|
+
case "up": app.swipeUp()
|
|
285
|
+
case "down": app.swipeDown()
|
|
286
|
+
case "left": app.swipeLeft()
|
|
287
|
+
default: app.swipeRight()
|
|
288
|
+
}
|
|
289
|
+
case "audit":
|
|
290
|
+
var issues: [String] = []
|
|
291
|
+
do {
|
|
292
|
+
try app.performAccessibilityAudit(for: [.sufficientElementDescription, .dynamicType, .contrast, .hitRegion]) { issue in
|
|
293
|
+
let label = issue.element?.label ?? ""
|
|
294
|
+
let id = issue.element?.identifier ?? ""
|
|
295
|
+
issues.append("\(issue.auditType.rawValue) \(issue.compactDescription) [\(label)|\(id)]")
|
|
296
|
+
return true
|
|
298
297
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
298
|
+
} catch { issues.append("audit error \(error)") }
|
|
299
|
+
post("/save?name=audit-\(arg).txt", Data(issues.joined(separator: "\n").utf8))
|
|
300
|
+
note("AUDIT \(arg): \(issues.count) issue(s)")
|
|
301
|
+
case "unlabelled":
|
|
302
|
+
let buttons = app.buttons.allElementsBoundByIndex.filter { $0.exists && $0.isHittable && $0.label.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
303
|
+
let where_ = buttons.prefix(5).map { "\(Int($0.frame.midX)),\(Int($0.frame.midY)) id=\($0.identifier)" }.joined(separator: "; ")
|
|
304
|
+
check(buttons.isEmpty, "unlabelled buttons \(arg) \(buttons.count) \(where_)")
|
|
305
|
+
case "dismiss":
|
|
306
|
+
// A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
|
|
307
|
+
let label = arg.isEmpty ? "Not Now" : arg
|
|
308
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
309
|
+
let candidates = [app.buttons[label], springboard.buttons[label]]
|
|
310
|
+
let end = Date().addingTimeInterval(6)
|
|
311
|
+
var done = false
|
|
312
|
+
while !done && Date() < end {
|
|
313
|
+
if let button = candidates.first(where: { $0.exists && $0.isHittable }) { button.tap(); done = true }
|
|
314
|
+
else { Thread.sleep(forTimeInterval: 0.5) }
|
|
302
315
|
}
|
|
316
|
+
note("dismiss \(label): \(done ? "tapped" : "none shown")")
|
|
317
|
+
case "log": note(arg)
|
|
318
|
+
default: XCTFail("Unknown command \(command)"); note("FAIL unknown command \(command)")
|
|
303
319
|
}
|
|
304
320
|
}
|
|
305
321
|
}
|