craft-native 0.0.156 → 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.
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.156";
3306
+ var version = "0.0.158";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.156";
313
+ var version = "0.0.158";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.156";
61
+ var version = "0.0.158";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -66,9 +66,11 @@ function serializeSimulatorSteps(steps) {
66
66
  throw new Error("Simulator identifiers must be nonempty single tokens");
67
67
  return value;
68
68
  };
69
- return steps.map((step) => {
69
+ const line = (step) => {
70
70
  let arg = "";
71
- if ("seconds" in step && !("text" in step))
71
+ if ("step" in step)
72
+ arg = `${token(step.id)} ${line(step.step)}`;
73
+ else if ("seconds" in step && !("text" in step))
72
74
  arg = String(number(step.seconds));
73
75
  else if ("milliseconds" in step)
74
76
  arg = String(number(step.milliseconds));
@@ -100,8 +102,9 @@ function serializeSimulatorSteps(steps) {
100
102
  if (/[\r\n]/.test(arg))
101
103
  throw new Error("Simulator arguments cannot contain newlines");
102
104
  return `${step.command}${arg ? ` ${arg}` : ""}`;
103
- }).join(`
104
- `) + `
105
+ };
106
+ return `${steps.map(line).join(`
107
+ `)}
105
108
  `;
106
109
  }
107
110
  function createNativeMutationValidator() {
@@ -36,7 +36,7 @@ export type SimulatorStep = {
36
36
  index: number;
37
37
  text: string;
38
38
  } | {
39
- command: 'typeid';
39
+ command: 'typeid' | 'labelid';
40
40
  id: string;
41
41
  text: string;
42
42
  } | {
@@ -52,6 +52,12 @@ export type SimulatorStep = {
52
52
  } | {
53
53
  command: 'openurl';
54
54
  url: string;
55
+ }
56
+ /** Runs `step` only when the element with this identifier is there (a screen shown the first time). */
57
+ | {
58
+ command: 'ifid';
59
+ id: string;
60
+ step: SimulatorStep;
55
61
  };
56
62
  /** Typed journeys use the same wire protocol as existing line-based scripts. */
57
63
  export declare function serializeSimulatorSteps(steps: readonly SimulatorStep[]): string;
@@ -2186,7 +2186,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2186
2186
 
2187
2187
  override func viewDidDisappear(_ animated: Bool) {
2188
2188
  super.viewDidDisappear(animated)
2189
- 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 {
2190
2197
  disposeRuntime()
2191
2198
  } else {
2192
2199
  screenVisibilityChanged(false)
@@ -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.156",
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>",