@craft-native/ios 0.0.205 → 0.0.207

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 CHANGED
@@ -172,6 +172,16 @@ the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
172
172
  `bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
173
173
  simulator UI tests.
174
174
 
175
+ `initSimulatorDriver` can also drive an installed release on a paired iPhone.
176
+ Pass `script: serializeSimulatorSteps(steps)` to bundle a journey in the test
177
+ target. `shot`, `tree` and `systemtree` then save native XCTest attachments;
178
+ checks appear in the Xcode test log. Generate the project with XcodeGen, sign
179
+ the test runner for your team and run it against the device. The installed app
180
+ under test is activated by its bundle identifier and is not rebuilt by the
181
+ driver. Export evidence with `xcrun xcresulttool export attachments --path
182
+ <result.xcresult> --output-path <evidence>`. Omitting `script` restores the
183
+ control-server protocol, required for the `net`, `delay` and `exec` hooks.
184
+
175
185
  The iOS host uses the shared tree-layout ABI for eligible definite-size,
176
186
  non-grid, non-wrapped containers and retains a local fallback for older
177
187
  runtimes or unsupported styles. Both paths implement wrapped-line and grid
package/dist/index.js CHANGED
@@ -102,6 +102,17 @@ function initSimulatorDriver(options) {
102
102
  if (previous !== content)
103
103
  writeFileSync(path, content);
104
104
  }
105
+ const script = join2(output, "UITests/journey.txt");
106
+ if (options.script === undefined)
107
+ rmSync(script, { force: true });
108
+ else {
109
+ let previous;
110
+ try {
111
+ previous = readFileSync(script, "utf8");
112
+ } catch {}
113
+ if (previous !== options.script)
114
+ writeFileSync(script, options.script);
115
+ }
105
116
  }
106
117
  function serializeSimulatorSteps(steps) {
107
118
  const number = (value) => {
package/dist/testing.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface SimulatorDriverOptions {
7
7
  deploymentTarget?: string;
8
8
  controlUrl?: string;
9
9
  tabs?: readonly string[];
10
+ /** Bundle a wire-protocol journey and save native XCTest attachments. No control server is needed on physical devices. */
11
+ script?: string;
10
12
  }
11
13
  /** Generate the reusable XCUITest shell from package templates. No app-owned Swift. */
12
14
  export declare function initSimulatorDriver(options: SimulatorDriverOptions): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.205",
3
+ "version": "0.0.207",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -854,9 +854,11 @@ final class CraftHybrid: NSObject {
854
854
  let show: (CraftHybridController) -> Void = { [weak self] controller in
855
855
  if native { controller.showNative(path: path, replace: false, fromLink: true) } else { self?.openOutside(url.absoluteString) }
856
856
  }
857
- guard let controller else {
857
+ guard let controller, controller.isViewLoaded else {
858
858
  // Before the shell: a hybrid app's page has it; an all-native app
859
859
  // opens it once the shell is up (a link that launched the app).
860
+ // Construction assigns controller before viewDidLoad makes the
861
+ // navigation stacks; a link in that interval must wait too.
860
862
  guard !hasWeb else { return false }
861
863
  untilShell.append(show)
862
864
  return true
@@ -348,6 +348,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
348
348
  }
349
349
  }
350
350
 
351
+ func setRefreshAppearance(_ props: [String: Any]) {
352
+ nativeRefreshControl.tintColor = (props["tintColor"] as? String).flatMap { UIColor(hex: $0) }
353
+ }
354
+
351
355
  @objc private func refreshTriggered() {
352
356
  refreshHandler?()
353
357
  DispatchQueue.main.async { [weak self] in
@@ -21,6 +21,13 @@ private func number(_ value: Any?) -> CGFloat? {
21
21
  return value.isFinite ? value : nil
22
22
  }
23
23
 
24
+ private func craftNativeFontWeight(_ value: Any?) -> String? {
25
+ if let value = value as? String { return value }
26
+ guard !(value is Bool), let number = value as? NSNumber, number.doubleValue.isFinite else { return nil }
27
+ let integer = number.doubleValue.rounded()
28
+ return integer == number.doubleValue ? String(Int(integer)) : nil
29
+ }
30
+
24
31
  private func craftNativePickerValue(_ value: Any?) -> String? {
25
32
  if let value = value as? String, !value.isEmpty { return value }
26
33
  if let value = value as? NSNumber, value.doubleValue.isFinite { return String(value.doubleValue) }
@@ -1228,6 +1235,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1228
1235
  return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
1229
1236
  }
1230
1237
 
1238
+ private func nonEmptyHandler(_ handler: String?) -> String? {
1239
+ guard let handler, !handler.isEmpty else { return nil }
1240
+ return handler
1241
+ }
1242
+
1231
1243
  private static func color(_ value: String?) -> UIColor {
1232
1244
  switch value?.lowercased() {
1233
1245
  case "red": return .systemRed
@@ -3318,6 +3330,7 @@ private final class CraftNativeSvgView: UIView {
3318
3330
  var linejoin: String?
3319
3331
  var dashes: [CGFloat]?
3320
3332
  var vectorEffect: String?
3333
+ var fontWeight: String?
3321
3334
  var dominantBaseline: String?
3322
3335
 
3323
3336
  func merged(_ node: [String: Any]) -> Paint {
@@ -3330,6 +3343,11 @@ private final class CraftNativeSvgView: UIView {
3330
3343
  let value = CGFloat(truncating: number)
3331
3344
  return value.isFinite ? value : nil
3332
3345
  }
3346
+ func string(_ raw: Any?) -> String? {
3347
+ if let value = raw as? String { return value }
3348
+ if let number = raw as? NSNumber { return number.stringValue }
3349
+ return nil
3350
+ }
3333
3351
  if let color = style["color"] as? String { next.color = color }
3334
3352
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
3335
3353
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
@@ -3346,6 +3364,7 @@ private final class CraftNativeSvgView: UIView {
3346
3364
  if let join = value("strokeLinejoin") as? String { next.linejoin = join }
3347
3365
  if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
3348
3366
  if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
3367
+ if let fontWeight = string(value("fontWeight")) { next.fontWeight = fontWeight }
3349
3368
  if let baseline = value("dominantBaseline") as? String { next.dominantBaseline = baseline }
3350
3369
  return next
3351
3370
  }
@@ -3446,7 +3465,14 @@ private final class CraftNativeSvgView: UIView {
3446
3465
  let props = node["props"] as? [String: Any] ?? [:]
3447
3466
  let style = node["style"] as? [String: Any] ?? [:]
3448
3467
  let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
3449
- let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
3468
+ let weight: UIFont.Weight = {
3469
+ switch paint.fontWeight?.lowercased() {
3470
+ case "500": return .medium
3471
+ case "600": return .semibold
3472
+ case "bold", "700", "800", "900": return .bold
3473
+ default: return .regular
3474
+ }
3475
+ }()
3450
3476
  let font = UIFont.systemFont(ofSize: size, weight: weight)
3451
3477
  let resolvedTextColor = paint.resolve(paint.fill) ?? UIColor.label
3452
3478
  let textAlpha = max(0, min(1, paint.opacity * paint.fillOpacity))
@@ -5779,6 +5805,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5779
5805
  updateImage(image, source: props["source"], defaultSource: props["defaultSource"])
5780
5806
  case "ImageBackground":
5781
5807
  let background = result as! CraftNativeImageBackgroundView
5808
+ let backgroundID = ObjectIdentifier(background)
5809
+ if let tint = color(style["tintColor"]) {
5810
+ tintedImages.insert(backgroundID)
5811
+ background.tintColor = tint
5812
+ } else {
5813
+ tintedImages.remove(backgroundID)
5814
+ background.tintColor = nil
5815
+ }
5816
+ if let current = background.image { background.image = imageForDisplay(current, view: background) }
5782
5817
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
5783
5818
  background.contentMode = imageContentMode(requestedResizeMode)
5784
5819
  background.clipsToBounds = background.contentMode == .scaleAspectFill
@@ -6139,6 +6174,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6139
6174
  let owner = parent.protocolId ?? path
6140
6175
  flatListOwners = flatListOwners.filter { $0.value != owner }
6141
6176
  for child in children { registerFlatListOwnership(child, owner: owner) }
6177
+ let propRefresh = props["refreshControl"] as? [String: Any] ?? [:]
6178
+ let propRefreshEvents = propRefresh["events"] as? [String: String] ?? [:]
6179
+ let refreshHandler = nonEmptyHandler(propRefreshEvents["onRefresh"])
6180
+ ?? nonEmptyHandler(events["onRefresh"])
6181
+ let refreshing = (propRefresh["refreshing"] ?? props["refreshing"]) as? Bool == true
6142
6182
  list.bounces = props["bounces"] as? Bool ?? true
6143
6183
  list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
6144
6184
  list.onFittingHeightChanged = { [weak list] in
@@ -6147,9 +6187,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6147
6187
  }
6148
6188
  list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
6149
6189
  list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
6150
- list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
6190
+ list.setRefreshHandler(refreshHandler.map { [weak self] handler in
6151
6191
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
6152
- }, refreshing: props["refreshing"] as? Bool == true)
6192
+ }, refreshing: refreshing)
6193
+ list.setRefreshAppearance(propRefresh)
6153
6194
  list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
6154
6195
  list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
6155
6196
  { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
@@ -6581,7 +6622,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6581
6622
  (icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
6582
6623
  let pointSize = fontSize ?? (box ?? 20) * 0.84
6583
6624
  let weight: UIImage.SymbolWeight
6584
- switch style["fontWeight"] as? String {
6625
+ switch craftNativeFontWeight(style["fontWeight"]) {
6585
6626
  case "100": weight = .ultraLight
6586
6627
  case "200": weight = .thin
6587
6628
  case "300": weight = .light
@@ -42,11 +42,14 @@ import XCTest
42
42
  /// ifid <id> <command…> runs the command only when that element is there (a screen shown the first time)
43
43
  /// log <text>
44
44
  ///
45
- /// Checks log `PASS …` / `FAIL …` lines into driver.log; matrix.sh counts them.
45
+ /// With a bundled journey.txt, evidence is kept as native XCTest attachments
46
+ /// and PASS/FAIL lines appear in the test log. Control-server journeys also
47
+ /// write driver.log; matrix.sh counts those checks.
46
48
  final class DriverTests: XCTestCase {
47
49
  let control = ProcessInfo.processInfo.environment["CRAFT_TEST_CONTROL"] ?? {{CONTROL_URL}}
48
50
  let app = XCUIApplication(bundleIdentifier: {{BUNDLE_ID}})
49
51
  let tabs: [String] = {{TABS}}
52
+ private var bundledJourney = false
50
53
 
51
54
  func get(_ path: String, timeout: Double = 15) -> Data? {
52
55
  var result: Data?
@@ -57,6 +60,15 @@ final class DriverTests: XCTestCase {
57
60
  }
58
61
 
59
62
  func post(_ path: String, _ body: Data) {
63
+ if bundledJourney {
64
+ guard path.hasPrefix("/save?"),
65
+ let name = URLComponents(string: path)?.queryItems?.first(where: { $0.name == "name" })?.value else { return }
66
+ let attachment = XCTAttachment(data: body, uniformTypeIdentifier: name.hasSuffix(".png") ? "public.png" : "public.plain-text")
67
+ attachment.name = name
68
+ attachment.lifetime = .keepAlways
69
+ add(attachment)
70
+ return
71
+ }
60
72
  var request = URLRequest(url: URL(string: control + path)!)
61
73
  request.httpMethod = "POST"
62
74
  request.httpBody = body
@@ -67,7 +79,7 @@ final class DriverTests: XCTestCase {
67
79
 
68
80
  func note(_ text: String) {
69
81
  print("DRIVER \(text)")
70
- post("/note", Data(text.utf8))
82
+ if !bundledJourney { post("/note", Data(text.utf8)) }
71
83
  }
72
84
 
73
85
  func check(_ ok: Bool, _ what: String) {
@@ -156,7 +168,14 @@ final class DriverTests: XCTestCase {
156
168
 
157
169
  func testScript() throws {
158
170
  continueAfterFailure = true
159
- guard let data = get("/script"), let script = String(data: data, encoding: .utf8) else {
171
+ let data: Data?
172
+ if let resource = Bundle(for: DriverTests.self).url(forResource: "journey", withExtension: "txt") {
173
+ bundledJourney = true
174
+ data = try Data(contentsOf: resource)
175
+ } else {
176
+ data = get("/script")
177
+ }
178
+ guard let data, let script = String(data: data, encoding: .utf8) else {
160
179
  XCTFail("no script")
161
180
  return
162
181
  }
@@ -171,6 +190,10 @@ final class DriverTests: XCTestCase {
171
190
  let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
172
191
  let command = parts[0]
173
192
  let arg = parts.count > 1 ? parts[1] : ""
193
+ if bundledJourney && ["net", "delay", "exec"].contains(command) {
194
+ check(false, "\(command) requires a control-server journey")
195
+ return
196
+ }
174
197
  switch command {
175
198
  case "ifid":
176
199
  let rest = arg.split(separator: " ", maxSplits: 1).map(String.init)