ReviewOS

also looking at this

craft-native/craft

fix(native): preserve workspace sources and native overlay lifecycle

#384
Merged chris wants to merge codex/windows-zig-cache-diagnostics into main
20 files +500 -227

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+111-47
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -52,11 +52,10 @@ final class NativeNavigationUITests: XCTestCase {
5252
5353 /// A scroll view can extend behind the software keyboard. Its covered
5454 /// portion is not a surface on which a real page drag can begin.
55 private func visibleViewport(_ scroll: XCUIElement) -> CGRect {
56 var viewport = scroll.frame
57 let keyboard = XCUIApplication().keyboards.firstMatch
58 if keyboard.exists {
59 let covered = viewport.intersection(keyboard.frame)
55 private func visibleViewport(_ frame: CGRect, keyboards: [CGRect]) -> CGRect {
56 var viewport = frame
57 if let keyboard = keyboards.first {
58 let covered = viewport.intersection(keyboard)
6059 if !covered.isNull && covered.width >= viewport.width - 1 {
6160 viewport.size.height = max(0, covered.minY - viewport.minY)
6261 }
@@ -64,12 +63,75 @@ final class NativeNavigationUITests: XCTestCase {
6463 return viewport
6564 }
6665
67 private func inside(_ element: XCUIElement, _ scroll: XCUIElement) -> Bool {
68 let frame = element.frame
69 let viewport = visibleViewport(scroll)
66 private func inside(_ frame: CGRect, _ viewport: CGRect) -> Bool {
7067 return !frame.isEmpty && frame.minY >= viewport.minY - 0.5 && frame.maxY <= viewport.maxY + 0.5
7168 }
7269
70 private let trackingTypes: Set<XCUIElement.ElementType> = [
71 .scrollView, .picker, .pickerWheel, .datePicker, .slider, .switch,
72 .stepper, .textField, .secureTextField, .textView, .segmentedControl,
73 ]
74
75 private func descendants(of snapshot: any XCUIElementSnapshot) -> [any XCUIElementSnapshot] {
76 snapshot.children.flatMap { [$0] + descendants(of: $0) }
77 }
78
79 private struct ScrollCapture {
80 let target: CGRect
81 let frame: CGRect
82 let viewport: CGRect
83 let controls: [CGRect]
84 let keyboards: [CGRect]
85 let scrolling: [(identifier: String, frame: CGRect)]
86 }
87
88 /// Capture each hierarchy once per gesture. Reading every XCUIElement's
89 /// frame separately performs another remote accessibility query per view.
90 private func captureScroll(
91 _ element: XCUIElement,
92 in scroll: XCUIElement,
93 application: XCUIApplication
94 ) throws -> ScrollCapture {
95 let scrollSnapshot = try scroll.snapshot()
96 let applicationNodes = descendants(of: try application.snapshot())
97 let keyboards = applicationNodes.filter { $0.elementType == .keyboard }.map { $0.frame }
98 return ScrollCapture(
99 target: element.frame,
100 frame: scrollSnapshot.frame,
101 viewport: visibleViewport(scrollSnapshot.frame, keyboards: keyboards),
102 controls: descendants(of: scrollSnapshot).filter { trackingTypes.contains($0.elementType) }.map { $0.frame },
103 keyboards: keyboards,
104 scrolling: applicationNodes.filter { $0.elementType == .scrollView }.map { (identifier: $0.identifier, frame: $0.frame) }
105 )
106 }
107
108 private func assertScrollCaptureMatchesLiveQueries(
109 _ element: XCUIElement,
110 in scroll: XCUIElement,
111 application: XCUIApplication
112 ) throws {
113 let capture = try captureScroll(element, in: scroll, application: application)
114 func assertFrames(_ actual: [CGRect], _ expected: [CGRect], _ name: String) {
115 XCTAssertEqual(actual.count, expected.count, "snapshot lost \(name)")
116 for frame in expected {
117 XCTAssertTrue(actual.contains { candidate in
118 candidate == frame || (
119 abs(candidate.minX - frame.minX) <= 0.5 && abs(candidate.minY - frame.minY) <= 0.5
120 && abs(candidate.width - frame.width) <= 0.5 && abs(candidate.height - frame.height) <= 0.5
121 )
122 }, "snapshot lost a \(name) frame: \(frame)")
123 }
124 }
125 let controls = scroll.descendants(matching: .any)
126 .matching(NSPredicate(format: "elementType IN %@", trackingTypes.map { NSNumber(value: $0.rawValue) }))
127 .allElementsBoundByIndex.map { $0.frame }
128 assertFrames(capture.controls, controls, "tracking control")
129 assertFrames(capture.keyboards, application.keyboards.allElementsBoundByIndex.map { $0.frame }, "keyboard")
130 let scrolling = application.scrollViews.allElementsBoundByIndex
131 assertFrames(capture.scrolling.map { $0.frame }, scrolling.map { $0.frame }, "scroll view")
132 XCTAssertEqual(capture.scrolling.map { $0.identifier }.sorted(), scrolling.map { $0.identifier }.sorted())
133 }
134
73135 private func manualScrollBlockers(
74136 controls: [CGRect],
75137 applicationScrollViews: [(identifier: String, frame: CGRect)],
@@ -226,46 +288,47 @@ final class NativeNavigationUITests: XCTestCase {
226288 if let root { activeScrollViews.insert(root.identifier) }
227289 // Budget for actual distance while requiring the same fully visible,
228290 // hittable target and retaining a hard bound on manual drags.
229 let distanceToTarget = abs(element.frame.midY - visibleViewport(scroll).midY)
230 let attempts = min(128, max(24, Int(ceil(distanceToTarget / 200)) + 8))
231 for attempt in 0..<attempts {
232 if inside(element, scroll) && element.isHittable { return }
233 let scrollFrame = scroll.frame
234 let viewport = visibleViewport(scroll)
235 // Native value controls, selection and nested scrolling retain
236 // their gesture ownership; search the remaining visible gaps.
237 let trackingTypes: [XCUIElement.ElementType] = [.scrollView, .picker, .pickerWheel, .datePicker, .slider, .switch, .stepper, .textField, .secureTextField, .textView, .segmentedControl]
238 let controls = scroll.descendants(matching: .any)
239 .matching(NSPredicate(format: "elementType IN %@", trackingTypes.map { NSNumber(value: $0.rawValue) }))
240 .allElementsBoundByIndex.map { $0.frame }
241 let keyboards = application.keyboards.allElementsBoundByIndex.map { $0.frame }
242 // Prediction strips can be sibling scroll surfaces outside the
243 // app's subtree. Exclude only the active scroll and its root.
244 let scrolling = application.scrollViews.allElementsBoundByIndex.map { (identifier: $0.identifier, frame: $0.frame) }
245 let competing = manualScrollBlockers(
246 controls: controls + keyboards,
247 applicationScrollViews: scrolling,
248 excluding: activeScrollViews
249 ).map { $0.insetBy(dx: -4, dy: -4) }
250 .filter { !$0.isEmpty && $0.intersects(viewport) }
251 guard let origin = manualScrollOrigin(in: viewport, avoiding: competing) else {
252 XCTFail("no safe manual scroll origin: target=\(element.frame), viewport=\(viewport), competing=\(competing)", file: file, line: line)
253 return
254 }
255 let start = CGVector(
256 dx: (viewport.minX + origin.dx * viewport.width - scrollFrame.minX) / scrollFrame.width,
257 dy: (viewport.minY + origin.dy * viewport.height - scrollFrame.minY) / scrollFrame.height
258 )
259 // Content moves the opposite way to the finger, which may leave
260 // a short scroll view: the drag stays its own until it lifts.
261 let distance = max(-240, min(240, element.frame.midY - viewport.midY))
262 let from = scroll.coordinate(withNormalizedOffset: start)
263 let to = from.withOffset(CGVector(dx: 0, dy: -distance))
264 XCTContext.runActivity(named: "manual scroll \(attempt + 1): target=\(element.frame), viewport=\(viewport), origin=\(start), distance=\(distance), competing=\(competing)") { _ in
265 from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.3)
291 do {
292 var capture = try captureScroll(element, in: scroll, application: application)
293 let distanceToTarget = abs(capture.target.midY - capture.viewport.midY)
294 let attempts = min(128, max(24, Int(ceil(distanceToTarget / 200)) + 8))
295 for attempt in 0..<attempts {
296 if attempt > 0 { capture = try captureScroll(element, in: scroll, application: application) }
297 if inside(capture.target, capture.viewport) && element.isHittable { return }
298 let scrollFrame = capture.frame
299 let viewport = capture.viewport
300 // Native value controls, selection and nested scrolling retain
301 // their gesture ownership; search the remaining visible gaps.
302 // Prediction strips can be sibling scroll surfaces outside the
303 // app's subtree. Exclude only the active scroll and its root.
304 let competing = manualScrollBlockers(
305 controls: capture.controls + capture.keyboards,
306 applicationScrollViews: capture.scrolling,
307 excluding: activeScrollViews
308 ).map { $0.insetBy(dx: -4, dy: -4) }
309 .filter { !$0.isEmpty && $0.intersects(viewport) }
310 guard let origin = manualScrollOrigin(in: viewport, avoiding: competing) else {
311 XCTFail("no safe manual scroll origin: target=\(capture.target), viewport=\(viewport), competing=\(competing)", file: file, line: line)
312 return
313 }
314 let start = CGVector(
315 dx: (viewport.minX + origin.dx * viewport.width - scrollFrame.minX) / scrollFrame.width,
316 dy: (viewport.minY + origin.dy * viewport.height - scrollFrame.minY) / scrollFrame.height
317 )
318 // Content moves the opposite way to the finger, which may leave
319 // a short scroll view: the drag stays its own until it lifts.
320 let distance = max(-240, min(240, capture.target.midY - viewport.midY))
321 let from = scroll.coordinate(withNormalizedOffset: start)
322 let to = from.withOffset(CGVector(dx: 0, dy: -distance))
323 XCTContext.runActivity(named: "manual scroll \(attempt + 1): target=\(capture.target), viewport=\(viewport), origin=\(start), distance=\(distance), competing=\(competing)") { _ in
324 from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.3)
325 }
266326 }
327 capture = try captureScroll(element, in: scroll, application: application)
328 XCTAssertTrue(inside(capture.target, capture.viewport) && element.isHittable, "control remained outside the native scroll viewport: target=\(capture.target), viewport=\(capture.frame), hittable=\(element.isHittable)", file: file, line: line)
329 } catch {
330 XCTFail("could not capture the native scroll hierarchy: \(error)", file: file, line: line)
267331 }
268 XCTAssertTrue(inside(element, scroll) && element.isHittable, "control remained outside the native scroll viewport: target=\(element.frame), viewport=\(scroll.frame), hittable=\(element.isHittable)", file: file, line: line)
269332 }
270333
271334 private func openCapabilities(_ app: XCUIApplication) {
@@ -355,7 +418,7 @@ final class NativeNavigationUITests: XCTestCase {
355418 XCTAssertEqual(app.staticTexts["safe-area-status"].label, "Safe edges: top,bottom", "Scrolling must cancel the button's tap")
356419 }
357420
358 func testMultilineInputKeepsKeyboardFocusWhenTapped() {
421 func testMultilineInputKeepsKeyboardFocusWhenTapped() throws {
359422 let app = XCUIApplication()
360423 app.launch()
361424 let root = app.scrollViews["native-root-scroll"]
@@ -374,6 +437,7 @@ final class NativeNavigationUITests: XCTestCase {
374437 XCTAssertEqual(notes.value as? String, "Draft edited")
375438 notes.tap()
376439 XCTAssertTrue(app.keyboards.firstMatch.exists, "Tapping the text area again must preserve editing")
440 try assertScrollCaptureMatchesLiveQueries(notes, in: root, application: app)
377441 }
378442
379443 func testHorizontalCarouselKeepsItsSwipe() {