craft-native 0.0.154 → 0.0.156
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 +46 -8
- package/dist/ios/src/index.js +289 -98
- package/dist/ios/src/testing.d.ts +71 -0
- 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 +90 -21
- package/dist/ios/templates/testing/DriverTests.swift.template +305 -0
- package/dist/ios/templates/testing/HostApp.swift +6 -0
- package/dist/ios/templates/testing/MutationValidator.swift +15 -0
- package/dist/ios/templates/testing/project.yml.template +36 -0
- 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?) {
|
|
@@ -2280,6 +2298,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2280
2298
|
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
2281
2299
|
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
2282
2300
|
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
2301
|
+
// The app's site and build, as JSON strings: the site an all-native
|
|
2302
|
+
// app's paths and links belong to (`devServerURL`), and the version a
|
|
2303
|
+
// request or a crash report names.
|
|
2304
|
+
let info = Bundle.main.infoDictionary
|
|
2305
|
+
let appURL = Self.jsonString(config.devServerURL ?? "")
|
|
2306
|
+
let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
|
|
2307
|
+
let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
|
|
2283
2308
|
jsContext.evaluateScript("""
|
|
2284
2309
|
globalThis.__stxNativeCallback = null;
|
|
2285
2310
|
globalThis.__stxNativeBridge = {
|
|
@@ -2290,6 +2315,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2290
2315
|
capabilities: \(capabilityJSON),
|
|
2291
2316
|
initialAppState: "\(CraftNativeActions.currentAppState())",
|
|
2292
2317
|
colorScheme: "\(colorScheme)",
|
|
2318
|
+
appURL: \(appURL),
|
|
2319
|
+
appVersion: \(appVersion),
|
|
2320
|
+
appBuild: \(appBuild),
|
|
2293
2321
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
2294
2322
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
2295
2323
|
};
|
|
@@ -2350,6 +2378,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2350
2378
|
""")
|
|
2351
2379
|
}
|
|
2352
2380
|
|
|
2381
|
+
/// `value` as a JavaScript string literal (JSON), quotes and all.
|
|
2382
|
+
static func jsonString(_ value: String) -> String {
|
|
2383
|
+
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
2384
|
+
let array = String(data: data, encoding: .utf8), array.count >= 2 else { return "\"\"" }
|
|
2385
|
+
return String(array.dropFirst().dropLast())
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2353
2388
|
/// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
|
|
2354
2389
|
/// the window's appearance once there is one, else the app's setting.
|
|
2355
2390
|
private var colorScheme: String {
|
|
@@ -2379,6 +2414,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2379
2414
|
/// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
|
|
2380
2415
|
/// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
|
|
2381
2416
|
/// promise): `Application Support/craft-snapshots/<name>.json`.
|
|
2417
|
+
/// `clear(keep?)` (a promise) deletes every snapshot but the names in
|
|
2418
|
+
/// `keep`, as a sign-out does; each removal is a `SNAPSHOT_CHANGED`
|
|
2419
|
+
/// for every screen.
|
|
2382
2420
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
2383
2421
|
/// `craft.secureStorage.set` wrote.
|
|
2384
2422
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
@@ -2405,6 +2443,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2405
2443
|
done.call(withArguments: [ok])
|
|
2406
2444
|
}
|
|
2407
2445
|
}
|
|
2446
|
+
let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
|
|
2447
|
+
let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
|
|
2448
|
+
CraftSnapshots.clear(keeping: Set(names)) { ok in
|
|
2449
|
+
done.call(withArguments: [ok])
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2408
2452
|
let config = self.config
|
|
2409
2453
|
let secureGet: @convention(block) (String) -> String? = { key in
|
|
2410
2454
|
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
@@ -2419,6 +2463,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2419
2463
|
jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
|
|
2420
2464
|
jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
|
|
2421
2465
|
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
2466
|
+
jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
|
|
2422
2467
|
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
2423
2468
|
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
2424
2469
|
// `AudioContext` and the rest of Web Audio, rendered natively.
|
|
@@ -2568,6 +2613,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2568
2613
|
else reject(new Error('craft.snapshots.set could not write ' + name));
|
|
2569
2614
|
});
|
|
2570
2615
|
});
|
|
2616
|
+
},
|
|
2617
|
+
// Every snapshot but the names in `keep` (a sign-out).
|
|
2618
|
+
clear: function(keep) {
|
|
2619
|
+
var names = Array.isArray(keep) ? keep.map(String) : [];
|
|
2620
|
+
return new Promise(function(resolve, reject) {
|
|
2621
|
+
craftNativeSnapshotClear(names, function(ok) {
|
|
2622
|
+
if (ok) resolve();
|
|
2623
|
+
else reject(new Error('craft.snapshots.clear could not remove every snapshot'));
|
|
2624
|
+
});
|
|
2625
|
+
});
|
|
2571
2626
|
}
|
|
2572
2627
|
});
|
|
2573
2628
|
keep('secureStorage', {
|
|
@@ -2623,6 +2678,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2623
2678
|
/// `craft.navigation.setOptions`: this screen's bar. Every key is
|
|
2624
2679
|
/// optional and only the keys given change.
|
|
2625
2680
|
private func applyNavigationOptions(_ options: [String: Any]) {
|
|
2681
|
+
// The app script has no screen, so no bar and no tab bar to change.
|
|
2682
|
+
guard !isAppScript else { return }
|
|
2626
2683
|
if options.keys.contains("title") {
|
|
2627
2684
|
navigationItem.title = options["title"] as? String
|
|
2628
2685
|
titleFromOptions = true
|
|
@@ -2826,20 +2883,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2826
2883
|
observe(UIApplication.didBecomeActiveNotification, state: "active")
|
|
2827
2884
|
observe(UIApplication.willResignActiveNotification, state: "inactive")
|
|
2828
2885
|
observe(UIApplication.didEnterBackgroundNotification, state: "background")
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2886
|
+
// The app script has no screen for the keyboard to cover.
|
|
2887
|
+
if !isAppScript {
|
|
2888
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
2889
|
+
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
2890
|
+
object: nil,
|
|
2891
|
+
queue: .main
|
|
2892
|
+
) { [weak self] notification in
|
|
2893
|
+
self?.applyKeyboardSafeArea(notification)
|
|
2894
|
+
})
|
|
2895
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
2896
|
+
forName: UIResponder.keyboardWillHideNotification,
|
|
2897
|
+
object: nil,
|
|
2898
|
+
queue: .main
|
|
2899
|
+
) { [weak self] notification in
|
|
2900
|
+
self?.applyKeyboardSafeArea(notification, hidden: true)
|
|
2901
|
+
})
|
|
2902
|
+
}
|
|
2843
2903
|
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
2844
2904
|
forName: CraftSnapshots.changed,
|
|
2845
2905
|
object: nil,
|
|
@@ -2873,6 +2933,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2873
2933
|
sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
|
|
2874
2934
|
streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
|
|
2875
2935
|
sendAppState(CraftNativeActions.currentAppState())
|
|
2936
|
+
// Links are the screens' to show; the app script hears none.
|
|
2937
|
+
guard !isAppScript else { return }
|
|
2876
2938
|
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
2877
2939
|
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
2878
2940
|
var payload = CraftNativeActions.deepLinkData(url)
|
|
@@ -2952,7 +3014,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2952
3014
|
}
|
|
2953
3015
|
|
|
2954
3016
|
private func sendAppState(_ state: String) {
|
|
2955
|
-
|
|
3017
|
+
// A screen hears it while it is on top of its stack; the app script,
|
|
3018
|
+
// which is on no stack, always.
|
|
3019
|
+
guard isAppScript || navigationController?.topViewController === self else { return }
|
|
2956
3020
|
send(type: "APP_STATE", payload: ["state": state])
|
|
2957
3021
|
}
|
|
2958
3022
|
|
|
@@ -2972,6 +3036,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2972
3036
|
/// is up, and are logged.
|
|
2973
3037
|
private func hybridFailed(_ message: String) {
|
|
2974
3038
|
guard !hybridRendered else { return }
|
|
3039
|
+
// The app script draws nothing for anything to stand in for: logged
|
|
3040
|
+
// (by the caller) and left to run on.
|
|
3041
|
+
guard !isAppScript else { return }
|
|
2975
3042
|
hybridEvents?.nativeScreen(self, didFail: message)
|
|
2976
3043
|
}
|
|
2977
3044
|
|
|
@@ -3014,7 +3081,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3014
3081
|
])
|
|
3015
3082
|
}
|
|
3016
3083
|
case "NAVIGATE", "NAVIGATE_REPLACE":
|
|
3017
|
-
|
|
3084
|
+
// A screen's own stack; the app script has none (it opens paths).
|
|
3085
|
+
guard !isAppScript, navigationController?.topViewController === self,
|
|
3018
3086
|
let screen = payload["screen"] as? String, !screen.isEmpty,
|
|
3019
3087
|
let navigation = navigationController else { return }
|
|
3020
3088
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
@@ -3034,13 +3102,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3034
3102
|
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
3035
3103
|
hybridEvents?.nativeScreen(self, open: path)
|
|
3036
3104
|
case "NAVIGATE_BACK":
|
|
3037
|
-
if navigationController?.topViewController === self {
|
|
3105
|
+
if !isAppScript, navigationController?.topViewController === self {
|
|
3038
3106
|
navigationController?.popViewController(animated: true)
|
|
3039
3107
|
}
|
|
3040
3108
|
case "NAVIGATE_POP_TO_ROOT":
|
|
3041
3109
|
// Back to the first screen of this screen's stack in one move: a
|
|
3042
3110
|
// finished task (a saved workout) closes the way it was reached.
|
|
3043
|
-
if navigationController?.topViewController === self {
|
|
3111
|
+
if !isAppScript, navigationController?.topViewController === self {
|
|
3044
3112
|
navigationController?.popToRootViewController(animated: true)
|
|
3045
3113
|
}
|
|
3046
3114
|
case "API_REQUEST":
|
|
@@ -3181,7 +3249,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3181
3249
|
renderCommitted(document)
|
|
3182
3250
|
if !hybridRendered {
|
|
3183
3251
|
hybridRendered = true
|
|
3184
|
-
|
|
3252
|
+
// The app script's drawing is never on screen: not a first frame.
|
|
3253
|
+
if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
|
|
3185
3254
|
}
|
|
3186
3255
|
}
|
|
3187
3256
|
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
/// Drives the installed app from a script the Mac serves (netswitch.ts
|
|
4
|
+
/// `/script`), one command a line:
|
|
5
|
+
///
|
|
6
|
+
/// launch | activate | terminate | home
|
|
7
|
+
/// background <secs> Home, wait, back to the app
|
|
8
|
+
/// wait <seconds>
|
|
9
|
+
/// net offline|online
|
|
10
|
+
/// delay <ms> every proxied request waits this long (0 = off)
|
|
11
|
+
/// exec <hook> runs the switch's HOOKS/<hook>.sh on the Mac (restart the API, …)
|
|
12
|
+
/// openurl <url> opens a link through the system, answering "Open in the app?"
|
|
13
|
+
/// shot <name> screenshot → shots/<name>.png
|
|
14
|
+
/// tree <name> accessibility tree → shots/<name>.txt
|
|
15
|
+
/// tap <text> first hittable element whose label contains text
|
|
16
|
+
/// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
|
|
17
|
+
/// tapid <identifier>
|
|
18
|
+
/// tapxy <x> <y> normalised 0…1 of the screen
|
|
19
|
+
/// tapexact <label> a button whose label is exactly this
|
|
20
|
+
/// back the navigation bar's back button, else an edge swipe
|
|
21
|
+
/// type <text> into the focused element
|
|
22
|
+
/// return the keyboard's return key (Go on a sign-in form)
|
|
23
|
+
/// field <n> <text> tap the n-th text field (0-based, secure fields after plain) and type
|
|
24
|
+
/// typeid <identifier> <text> tap the element with this identifier and type
|
|
25
|
+
/// expect <text> [| secs] waits for an element containing text; logs PASS/FAIL
|
|
26
|
+
/// expectexact <label> [| secs] a button or text whose label is exactly this
|
|
27
|
+
/// absent <text> logs PASS when no element contains text
|
|
28
|
+
/// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID)
|
|
29
|
+
/// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
|
|
30
|
+
/// selected <tab> the tab bar's selected button is this one
|
|
31
|
+
/// tab <name> tap a tab bar button
|
|
32
|
+
/// scrollto <text> swipes up (at most 8 times) until text is hittable
|
|
33
|
+
/// swipe up|down|left|right
|
|
34
|
+
/// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
|
|
35
|
+
/// unlabelled logs every hittable button without a label (FAIL if any)
|
|
36
|
+
/// dismiss [label] a system prompt's button (default Not Now: "Save Password?")
|
|
37
|
+
/// log <text>
|
|
38
|
+
///
|
|
39
|
+
/// Checks log `PASS …` / `FAIL …` lines into driver.log; matrix.sh counts them.
|
|
40
|
+
final class DriverTests: XCTestCase {
|
|
41
|
+
let control = ProcessInfo.processInfo.environment["CRAFT_TEST_CONTROL"] ?? {{CONTROL_URL}}
|
|
42
|
+
let app = XCUIApplication(bundleIdentifier: {{BUNDLE_ID}})
|
|
43
|
+
let tabs: [String] = {{TABS}}
|
|
44
|
+
|
|
45
|
+
func get(_ path: String, timeout: Double = 15) -> Data? {
|
|
46
|
+
var result: Data?
|
|
47
|
+
let done = expectation(description: path)
|
|
48
|
+
URLSession.shared.dataTask(with: URL(string: control + path)!) { data, _, _ in result = data; done.fulfill() }.resume()
|
|
49
|
+
wait(for: [done], timeout: timeout)
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func post(_ path: String, _ body: Data) {
|
|
54
|
+
var request = URLRequest(url: URL(string: control + path)!)
|
|
55
|
+
request.httpMethod = "POST"
|
|
56
|
+
request.httpBody = body
|
|
57
|
+
let done = expectation(description: path)
|
|
58
|
+
URLSession.shared.dataTask(with: request) { _, _, _ in done.fulfill() }.resume()
|
|
59
|
+
wait(for: [done], timeout: 15)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func note(_ text: String) {
|
|
63
|
+
print("DRIVER \(text)")
|
|
64
|
+
post("/note", Data(text.utf8))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func check(_ ok: Bool, _ what: String) {
|
|
68
|
+
note(ok ? "PASS \(what)" : "FAIL \(what)")
|
|
69
|
+
XCTAssertTrue(ok, what)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func element(containing text: String) -> XCUIElement? {
|
|
73
|
+
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR value CONTAINS[c] %@ OR identifier == %@", text, text, text)
|
|
74
|
+
for query in [app.buttons, app.links, app.staticTexts, app.otherElements, app.cells, app.textFields] {
|
|
75
|
+
let matches = query.matching(predicate)
|
|
76
|
+
let count = min(matches.count, 25)
|
|
77
|
+
for index in 0..<count {
|
|
78
|
+
let candidate = matches.element(boundBy: index)
|
|
79
|
+
if candidate.exists && candidate.isHittable { return candidate }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let any = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
83
|
+
return any.exists ? any : nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func exact(_ label: String) -> XCUIElement? {
|
|
87
|
+
let predicate = NSPredicate(format: "label == %@", label)
|
|
88
|
+
for query in [app.buttons, app.links, app.staticTexts] {
|
|
89
|
+
let matches = query.matching(predicate)
|
|
90
|
+
for index in 0..<min(matches.count, 25) {
|
|
91
|
+
let candidate = matches.element(boundBy: index)
|
|
92
|
+
if candidate.exists && candidate.isHittable { return candidate }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func poll(_ seconds: Double, _ find: () -> XCUIElement?) -> XCUIElement? {
|
|
99
|
+
let end = Date().addingTimeInterval(seconds)
|
|
100
|
+
repeat {
|
|
101
|
+
if let found = find() { return found }
|
|
102
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
103
|
+
} while Date() < end
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func waitFor(_ text: String, _ seconds: Double) -> XCUIElement? {
|
|
108
|
+
poll(seconds) { element(containing: text) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// The native tab bar's button: Craft's floating bar is not a UITabBar, and
|
|
112
|
+
/// XCUITest calls its buttons not hittable, so it is the button with this
|
|
113
|
+
/// label in the bottom fifth of the window.
|
|
114
|
+
func tabButton(_ name: String) -> XCUIElement? {
|
|
115
|
+
let height = app.windows.firstMatch.frame.height
|
|
116
|
+
let matches = app.buttons.matching(NSPredicate(format: "label == %@", name))
|
|
117
|
+
for index in 0..<min(matches.count, 10) {
|
|
118
|
+
let candidate = matches.element(boundBy: index)
|
|
119
|
+
if candidate.exists && candidate.frame.minY > height * 0.8 && candidate.frame.maxY <= height + 1 && candidate.frame.width > 0 { return candidate }
|
|
120
|
+
}
|
|
121
|
+
return nil
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func tabBarShown() -> Bool {
|
|
125
|
+
let required = min(3, tabs.count)
|
|
126
|
+
return required > 0 && tabs.filter { tabButton($0) != nil }.count >= required
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func split(_ arg: String) -> (String, Double) {
|
|
130
|
+
let parts = arg.split(separator: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
131
|
+
return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// A tap that never fails the run: an element covered a moment (the big
|
|
135
|
+
/// timer coming up) is tapped where it is, and the check after says.
|
|
136
|
+
func press(_ element: XCUIElement) {
|
|
137
|
+
if element.isHittable { element.tap() }
|
|
138
|
+
else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func answerOpenPrompt() {
|
|
142
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
143
|
+
let open = springboard.buttons["Open"]
|
|
144
|
+
if open.waitForExistence(timeout: 4) { open.tap() }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func testScript() throws {
|
|
148
|
+
continueAfterFailure = true
|
|
149
|
+
guard let data = get("/script"), let script = String(data: data, encoding: .utf8) else {
|
|
150
|
+
XCTFail("no script")
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
for raw in script.split(separator: "\n") {
|
|
154
|
+
let line = raw.trimmingCharacters(in: .whitespaces)
|
|
155
|
+
if line.isEmpty || line.hasPrefix("#") { continue }
|
|
156
|
+
let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
|
|
157
|
+
let command = parts[0]
|
|
158
|
+
let arg = parts.count > 1 ? parts[1] : ""
|
|
159
|
+
switch command {
|
|
160
|
+
case "launch": app.launch()
|
|
161
|
+
case "activate": app.activate()
|
|
162
|
+
case "terminate": app.terminate()
|
|
163
|
+
case "home": XCUIDevice.shared.press(.home)
|
|
164
|
+
case "background":
|
|
165
|
+
XCUIDevice.shared.press(.home)
|
|
166
|
+
Thread.sleep(forTimeInterval: Double(arg) ?? 3)
|
|
167
|
+
app.activate()
|
|
168
|
+
case "wait": Thread.sleep(forTimeInterval: Double(arg) ?? 1)
|
|
169
|
+
case "net": _ = get("/\(arg)"); note("net \(arg)")
|
|
170
|
+
case "delay": _ = get("/delay?ms=\(arg)"); note("delay \(arg)ms")
|
|
171
|
+
case "exec":
|
|
172
|
+
let out = get("/exec?name=\(arg)", timeout: 180).flatMap { String(data: $0, encoding: .utf8) } ?? "no answer"
|
|
173
|
+
note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
|
|
174
|
+
case "openurl":
|
|
175
|
+
if let url = URL(string: arg) {
|
|
176
|
+
XCUIDevice.shared.system.open(url)
|
|
177
|
+
answerOpenPrompt()
|
|
178
|
+
note("openurl \(arg)")
|
|
179
|
+
}
|
|
180
|
+
case "shot":
|
|
181
|
+
let shot = XCUIScreen.main.screenshot()
|
|
182
|
+
post("/save?name=\(arg).png", shot.pngRepresentation)
|
|
183
|
+
case "tree":
|
|
184
|
+
post("/save?name=\(arg).txt", Data(app.debugDescription.utf8))
|
|
185
|
+
case "tap":
|
|
186
|
+
if let target = waitFor(arg, 10) { press(target); note("tap \(arg): ok") }
|
|
187
|
+
else { XCTFail("Element not found"); note("FAIL tap \(arg): NOT FOUND") }
|
|
188
|
+
case "tapif":
|
|
189
|
+
if let target = element(containing: arg), target.isHittable { press(target); note("tapif \(arg): tapped") }
|
|
190
|
+
else { note("tapif \(arg): not shown") }
|
|
191
|
+
case "tapexact":
|
|
192
|
+
if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
|
|
193
|
+
else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
|
|
194
|
+
case "tapid":
|
|
195
|
+
let target = app.descendants(matching: .any)[arg]
|
|
196
|
+
if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
|
|
197
|
+
else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
198
|
+
case "tapxy":
|
|
199
|
+
let xy = arg.split(separator: " ").compactMap { Double($0) }
|
|
200
|
+
if xy.count == 2 && xy.allSatisfy({ $0 >= 0 && $0 <= 1 }) {
|
|
201
|
+
app.coordinate(withNormalizedOffset: CGVector(dx: xy[0], dy: xy[1])).tap()
|
|
202
|
+
} else { XCTFail("tapxy needs two coordinates between 0 and 1") }
|
|
203
|
+
case "back":
|
|
204
|
+
let bar = app.navigationBars.buttons.firstMatch
|
|
205
|
+
if bar.exists && bar.isHittable { bar.tap(); note("back: bar button") }
|
|
206
|
+
else {
|
|
207
|
+
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
|
|
208
|
+
start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
|
|
209
|
+
note("back: edge swipe")
|
|
210
|
+
}
|
|
211
|
+
case "type": app.typeText(arg)
|
|
212
|
+
case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
|
|
213
|
+
case "typeid":
|
|
214
|
+
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
215
|
+
let target = app.descendants(matching: .any)[idParts[0]]
|
|
216
|
+
if target.waitForExistence(timeout: 10) {
|
|
217
|
+
target.tap()
|
|
218
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
219
|
+
app.typeText(idParts.count > 1 ? idParts[1] : "")
|
|
220
|
+
note("typeid \(idParts[0]): ok")
|
|
221
|
+
} else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
|
|
222
|
+
case "field":
|
|
223
|
+
let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
224
|
+
let index = Int(fieldParts[0]) ?? 0
|
|
225
|
+
let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
|
|
226
|
+
if index >= 0 && index < fields.count {
|
|
227
|
+
fields[index].tap()
|
|
228
|
+
Thread.sleep(forTimeInterval: 0.6)
|
|
229
|
+
app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
|
|
230
|
+
note("field \(index): ok")
|
|
231
|
+
} else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
|
|
232
|
+
case "expect":
|
|
233
|
+
let (text, seconds) = split(arg)
|
|
234
|
+
check(waitFor(text, seconds) != nil, "expect \(text)")
|
|
235
|
+
case "expectexact":
|
|
236
|
+
let (text, seconds) = split(arg)
|
|
237
|
+
check(poll(seconds, { exact(text) }) != nil, "expectexact \(text)")
|
|
238
|
+
case "expectid":
|
|
239
|
+
let (id, seconds) = split(arg)
|
|
240
|
+
check(app.descendants(matching: .any)[id].waitForExistence(timeout: seconds), "expectid \(id)")
|
|
241
|
+
case "absentid":
|
|
242
|
+
check(!app.descendants(matching: .any)[arg].exists, "absentid \(arg)")
|
|
243
|
+
case "absent":
|
|
244
|
+
check(element(containing: arg) == nil, "absent \(arg)")
|
|
245
|
+
case "tabbar":
|
|
246
|
+
let want = arg != "hidden"
|
|
247
|
+
let end = Date().addingTimeInterval(5)
|
|
248
|
+
var shown = tabBarShown()
|
|
249
|
+
while shown != want && Date() < end { Thread.sleep(forTimeInterval: 0.5); shown = tabBarShown() }
|
|
250
|
+
check(shown == want, "tabbar \(arg)")
|
|
251
|
+
case "selected":
|
|
252
|
+
check(poll(5, { tabButton(arg).flatMap { $0.isSelected ? $0 : nil } }) != nil, "selected \(arg)")
|
|
253
|
+
case "tab":
|
|
254
|
+
if let button = poll(10, { tabButton(arg) }) { button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap(); note("tab \(arg): ok") }
|
|
255
|
+
else { XCTFail("Element not found"); note("FAIL tab \(arg): NOT FOUND") }
|
|
256
|
+
case "scrollto":
|
|
257
|
+
var found = element(containing: arg)
|
|
258
|
+
var swipes = 0
|
|
259
|
+
while (found == nil || !(found!.isHittable)) && swipes < 8 {
|
|
260
|
+
app.swipeUp()
|
|
261
|
+
swipes += 1
|
|
262
|
+
found = element(containing: arg)
|
|
263
|
+
}
|
|
264
|
+
check(found != nil && found!.isHittable, "scrollto \(arg)")
|
|
265
|
+
case "swipe":
|
|
266
|
+
switch arg {
|
|
267
|
+
case "up": app.swipeUp()
|
|
268
|
+
case "down": app.swipeDown()
|
|
269
|
+
case "left": app.swipeLeft()
|
|
270
|
+
default: app.swipeRight()
|
|
271
|
+
}
|
|
272
|
+
case "audit":
|
|
273
|
+
var issues: [String] = []
|
|
274
|
+
do {
|
|
275
|
+
try app.performAccessibilityAudit(for: [.sufficientElementDescription, .dynamicType, .contrast, .hitRegion]) { issue in
|
|
276
|
+
let label = issue.element?.label ?? ""
|
|
277
|
+
let id = issue.element?.identifier ?? ""
|
|
278
|
+
issues.append("\(issue.auditType.rawValue) \(issue.compactDescription) [\(label)|\(id)]")
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
} catch { issues.append("audit error \(error)") }
|
|
282
|
+
post("/save?name=audit-\(arg).txt", Data(issues.joined(separator: "\n").utf8))
|
|
283
|
+
note("AUDIT \(arg): \(issues.count) issue(s)")
|
|
284
|
+
case "unlabelled":
|
|
285
|
+
let buttons = app.buttons.allElementsBoundByIndex.filter { $0.exists && $0.isHittable && $0.label.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
286
|
+
let where_ = buttons.prefix(5).map { "\(Int($0.frame.midX)),\(Int($0.frame.midY)) id=\($0.identifier)" }.joined(separator: "; ")
|
|
287
|
+
check(buttons.isEmpty, "unlabelled buttons \(arg) \(buttons.count) \(where_)")
|
|
288
|
+
case "dismiss":
|
|
289
|
+
// A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
|
|
290
|
+
let label = arg.isEmpty ? "Not Now" : arg
|
|
291
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
292
|
+
let candidates = [app.buttons[label], springboard.buttons[label]]
|
|
293
|
+
let end = Date().addingTimeInterval(6)
|
|
294
|
+
var done = false
|
|
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) }
|
|
298
|
+
}
|
|
299
|
+
note("dismiss \(label): \(done ? "tapped" : "none shown")")
|
|
300
|
+
case "log": note(arg)
|
|
301
|
+
default: XCTFail("Unknown command \(command)"); note("FAIL unknown command \(command)")
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
let messages = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]))) as! [[String: Any]]
|
|
3
|
+
func report(_ message: String) {
|
|
4
|
+
try! (message + "\n").write(to: URL(fileURLWithPath: CommandLine.arguments[2]), atomically: true, encoding: .utf8)
|
|
5
|
+
}
|
|
6
|
+
let document = CraftNativeMutationDocument()
|
|
7
|
+
for message in messages {
|
|
8
|
+
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
9
|
+
if message["type"] as? String == "RENDER" { document.replace(with: payload["document"] as! [String: Any]) }
|
|
10
|
+
if message["type"] as? String == "MUTATE" {
|
|
11
|
+
do { _ = try document.apply(payload) }
|
|
12
|
+
catch let failure as CraftNativeMutationFailure { report("\(failure.code): \(failure.message) at operation \(failure.operationIndex ?? -1)"); exit(1) }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
report("Accepted all native update batches")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: {{PROJECT_NAME}}
|
|
2
|
+
options:
|
|
3
|
+
bundleIdPrefix: {{BUNDLE_PREFIX}}
|
|
4
|
+
deploymentTarget:
|
|
5
|
+
iOS: "{{IOS_VERSION}}"
|
|
6
|
+
targets:
|
|
7
|
+
{{PROJECT_NAME}}Host:
|
|
8
|
+
type: application
|
|
9
|
+
platform: iOS
|
|
10
|
+
sources: [Host]
|
|
11
|
+
settings:
|
|
12
|
+
PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_PREFIX}}.host
|
|
13
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
14
|
+
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
|
15
|
+
SWIFT_VERSION: "5.0"
|
|
16
|
+
CODE_SIGNING_ALLOWED: NO
|
|
17
|
+
{{PROJECT_NAME}}UITests:
|
|
18
|
+
type: bundle.ui-testing
|
|
19
|
+
platform: iOS
|
|
20
|
+
sources: [UITests]
|
|
21
|
+
settings:
|
|
22
|
+
TEST_TARGET_NAME: {{PROJECT_NAME}}Host
|
|
23
|
+
PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_PREFIX}}.uitests
|
|
24
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
25
|
+
SWIFT_VERSION: "5.0"
|
|
26
|
+
CODE_SIGNING_ALLOWED: NO
|
|
27
|
+
dependencies:
|
|
28
|
+
- target: {{PROJECT_NAME}}Host
|
|
29
|
+
schemes:
|
|
30
|
+
{{PROJECT_NAME}}:
|
|
31
|
+
build:
|
|
32
|
+
targets:
|
|
33
|
+
{{PROJECT_NAME}}Host: all
|
|
34
|
+
{{PROJECT_NAME}}UITests: [test]
|
|
35
|
+
test:
|
|
36
|
+
targets: [{{PROJECT_NAME}}UITests]
|