@craft-native/ios 0.0.153 → 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.
- package/README.md +5 -1
- package/dist/cli.js +95 -75
- package/dist/index.d.ts +4 -0
- package/dist/index.js +249 -90
- package/dist/testing.d.ts +71 -0
- package/package.json +1 -1
- package/templates/CraftNativeActions.swift +10 -0
- package/templates/CraftNativeFiles.swift +34 -0
- package/templates/CraftNativeServices.swift +5 -1
- package/templates/testing/DriverTests.swift.template +305 -0
- package/templates/testing/HostApp.swift +6 -0
- package/templates/testing/MutationValidator.swift +15 -0
- package/templates/testing/project.yml.template +36 -0
|
@@ -171,6 +171,16 @@ enum CraftNativeActions {
|
|
|
171
171
|
case ("Files", "download"):
|
|
172
172
|
download(requestToken: requestToken, args: args, completion: completion)
|
|
173
173
|
return
|
|
174
|
+
case ("Files", "write"):
|
|
175
|
+
// Something the screen made (a CSV export) as a file for the
|
|
176
|
+
// share sheet; the disk work is off the main thread.
|
|
177
|
+
let name = args.first as? String ?? ""
|
|
178
|
+
let data = args.count > 1 ? args[1] : nil
|
|
179
|
+
let options = args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:]
|
|
180
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
181
|
+
complete(requestToken, CraftNativeFiles.write(name: name, data: data, options: options), completion)
|
|
182
|
+
}
|
|
183
|
+
return
|
|
174
184
|
case ("Files", "remove"):
|
|
175
185
|
sync = .success(CraftNativeFiles.remove(args.first as? String ?? ""))
|
|
176
186
|
case ("Lifecycle", "getState"):
|
|
@@ -329,6 +329,40 @@ enum CraftNativeFiles {
|
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
/// What a screen made itself (a CSV export, a route it built) written as
|
|
333
|
+
/// a file for the share sheet: `data` is text (`encoding: 'utf8'`, the
|
|
334
|
+
/// default, so a CSV crosses the bridge once and no larger) or base64
|
|
335
|
+
/// (`encoding: 'base64'`, for bytes). What is already on a server is
|
|
336
|
+
/// `download`ed instead, straight to disk.
|
|
337
|
+
static func write(name: String, data: Any?, options: [String: Any]) -> Result<Any, CraftNativeActionError> {
|
|
338
|
+
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
339
|
+
return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write needs a file name"))
|
|
340
|
+
}
|
|
341
|
+
guard let text = data as? String else {
|
|
342
|
+
return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write needs its contents as a string"))
|
|
343
|
+
}
|
|
344
|
+
let bytes: Data
|
|
345
|
+
switch options["encoding"] as? String ?? "utf8" {
|
|
346
|
+
case "utf8":
|
|
347
|
+
bytes = Data(text.utf8)
|
|
348
|
+
case "base64":
|
|
349
|
+
guard let decoded = Data(base64Encoded: text.filter { !$0.isWhitespace }) else {
|
|
350
|
+
return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write was given base64 it could not read"))
|
|
351
|
+
}
|
|
352
|
+
bytes = decoded
|
|
353
|
+
default:
|
|
354
|
+
return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write encoding is 'utf8' or 'base64'"))
|
|
355
|
+
}
|
|
356
|
+
let type = (options["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
|
|
357
|
+
do {
|
|
358
|
+
let url = try newFile(named: name)
|
|
359
|
+
try bytes.write(to: url, options: .atomic)
|
|
360
|
+
return .success(describe(url, type: type))
|
|
361
|
+
} catch {
|
|
362
|
+
return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
332
366
|
/// A refused download: its status and the start of its answer, so the
|
|
333
367
|
/// screen can say what the server said. No file is kept.
|
|
334
368
|
static func refusedDownload(at location: URL?, response: HTTPURLResponse) -> [String: Any] {
|
|
@@ -527,7 +527,11 @@ enum CraftNativeShare {
|
|
|
527
527
|
if let title = options["title"] as? String, !title.isEmpty { items.append(title) }
|
|
528
528
|
if let text = options["text"] as? String, !text.isEmpty { items.append(text) }
|
|
529
529
|
if let address = options["url"] as? String, let url = URL(string: address) { items.append(url) }
|
|
530
|
-
|
|
530
|
+
// A path or file URL, or a file craft.files made (`{ path, name }`).
|
|
531
|
+
let files = (options["files"] as? [Any] ?? []).compactMap { item -> String? in
|
|
532
|
+
item as? String ?? (item as? [String: Any])?["path"] as? String
|
|
533
|
+
}
|
|
534
|
+
for file in files {
|
|
531
535
|
if let url = URL(string: file), url.isFileURL { items.append(url) }
|
|
532
536
|
else if FileManager.default.fileExists(atPath: file) { items.append(URL(fileURLWithPath: file)) }
|
|
533
537
|
}
|
|
@@ -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]
|