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.
@@ -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?) {
@@ -2168,7 +2186,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2168
2186
 
2169
2187
  override func viewDidDisappear(_ animated: Bool) {
2170
2188
  super.viewDidDisappear(animated)
2171
- if isMovingFromParent || navigationController?.viewControllers.contains(where: { $0 === self }) == false {
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
- 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
- })
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
- guard navigationController?.topViewController === self else { return }
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
- guard navigationController?.topViewController === self,
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
- hybridEvents?.nativeScreenDidRender(self)
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
- 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) }
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
- note("dismiss \(label): \(done ? "tapped" : "none shown")")
300
- case "log": note(arg)
301
- default: XCTFail("Unknown command \(command)"); note("FAIL unknown command \(command)")
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.155",
3
+ "version": "0.0.158",
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>",