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.
@@ -367,7 +367,12 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
367
367
  let config: CraftConfig
368
368
 
369
369
  func makeUIViewController(context: Context) -> UINavigationController {
370
- UINavigationController(rootViewController: CraftNativeScreenController(config: config))
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
- lifecycleObservers.append(NotificationCenter.default.addObserver(
2830
- forName: UIResponder.keyboardWillChangeFrameNotification,
2831
- object: nil,
2832
- queue: .main
2833
- ) { [weak self] notification in
2834
- self?.applyKeyboardSafeArea(notification)
2835
- })
2836
- lifecycleObservers.append(NotificationCenter.default.addObserver(
2837
- forName: UIResponder.keyboardWillHideNotification,
2838
- object: nil,
2839
- queue: .main
2840
- ) { [weak self] notification in
2841
- self?.applyKeyboardSafeArea(notification, hidden: true)
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
- guard navigationController?.topViewController === self else { return }
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
- guard navigationController?.topViewController === self,
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
- hybridEvents?.nativeScreenDidRender(self)
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,6 @@
1
+ import SwiftUI
2
+
3
+ @main
4
+ struct HostApp: App {
5
+ var body: some Scene { WindowGroup { Text("Craft simulator driver") } }
6
+ }
@@ -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]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.154",
3
+ "version": "0.0.156",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",