also looking at this
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.
| @@ -52,11 +52,10 @@ final class NativeNavigationUITests: XCTestCase { | ||
| 52 | 52 | |
| 53 | 53 | /// A scroll view can extend behind the software keyboard. Its covered |
| 54 | 54 | /// 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) | |
| 60 | 59 | if !covered.isNull && covered.width >= viewport.width - 1 { |
| 61 | 60 | viewport.size.height = max(0, covered.minY - viewport.minY) |
| 62 | 61 | } |
| @@ -64,12 +63,75 @@ final class NativeNavigationUITests: XCTestCase { | ||
| 64 | 63 | return viewport |
| 65 | 64 | } |
| 66 | 65 | |
| 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 { | |
| 70 | 67 | return !frame.isEmpty && frame.minY >= viewport.minY - 0.5 && frame.maxY <= viewport.maxY + 0.5 |
| 71 | 68 | } |
| 72 | 69 | |
| 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 | + | |
| 73 | 135 | private func manualScrollBlockers( |
| 74 | 136 | controls: [CGRect], |
| 75 | 137 | applicationScrollViews: [(identifier: String, frame: CGRect)], |
| @@ -226,46 +288,47 @@ final class NativeNavigationUITests: XCTestCase { | ||
| 226 | 288 | if let root { activeScrollViews.insert(root.identifier) } |
| 227 | 289 | // Budget for actual distance while requiring the same fully visible, |
| 228 | 290 | // 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 | + } | |
| 266 | 326 | } |
| 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) | |
| 267 | 331 | } |
| 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) | |
| 269 | 332 | } |
| 270 | 333 | |
| 271 | 334 | private func openCapabilities(_ app: XCUIApplication) { |
| @@ -355,7 +418,7 @@ final class NativeNavigationUITests: XCTestCase { | ||
| 355 | 418 | XCTAssertEqual(app.staticTexts["safe-area-status"].label, "Safe edges: top,bottom", "Scrolling must cancel the button's tap") |
| 356 | 419 | } |
| 357 | 420 | |
| 358 | - func testMultilineInputKeepsKeyboardFocusWhenTapped() { | |
| 421 | + func testMultilineInputKeepsKeyboardFocusWhenTapped() throws { | |
| 359 | 422 | let app = XCUIApplication() |
| 360 | 423 | app.launch() |
| 361 | 424 | let root = app.scrollViews["native-root-scroll"] |
| @@ -374,6 +437,7 @@ final class NativeNavigationUITests: XCTestCase { | ||
| 374 | 437 | XCTAssertEqual(notes.value as? String, "Draft edited") |
| 375 | 438 | notes.tap() |
| 376 | 439 | XCTAssertTrue(app.keyboards.firstMatch.exists, "Tapping the text area again must preserve editing") |
| 440 | + try assertScrollCaptureMatchesLiveQueries(notes, in: root, application: app) | |
| 377 | 441 | } |
| 378 | 442 | |
| 379 | 443 | func testHorizontalCarouselKeepsItsSwipe() { |