@craft-native/ios 0.0.154 → 0.0.155

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.
@@ -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]