import XCTest import WMF let pressDuration = 0.1 // don't set this to too large a value or presses which happen to land on buttons will activate the button instead of letting the drag event bubble up extension XCUIElement { @discardableResult func wmf_tap() -> Bool { guard exists else { return false } if isHittable { tap() } else { coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)).tap() } return true } func wmf_typeText(text: String) -> Bool { guard exists else { return false } typeText(text) return true } func wmf_waitUntilExists(timeout: TimeInterval = 10) -> XCUIElement? { if exists && isHittable { return self } let result = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND isHittable = true"), object: self)], timeout: timeout) if result != .completed { return nil } return self } func wmf_firstButton(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> XCUIElement? { return buttons.wmf_firstElement(with: .label, withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) } @discardableResult func wmf_tapFirstButton(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> Bool { if let firstButton = wmf_firstButton(withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) { return firstButton.wmf_tap() } else { return false } } func wmf_firstStaticText(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> XCUIElement? { return staticTexts.wmf_firstElement(with: .label, withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) } @discardableResult func wmf_tapFirstStaticText(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> Bool { if let firstStaticText = wmf_firstStaticText(withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) { return firstStaticText.wmf_tap() } else { return false } } func wmf_firstSwitch(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> XCUIElement? { return switches.wmf_firstElement(with: .label, withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) } @discardableResult func wmf_tapFirstSwitch(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> Bool { if let firstSwitch = wmf_firstSwitch(withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) { return firstSwitch.wmf_tap() } else { return false } } @discardableResult func wmf_firstSearchField(withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false) -> XCUIElement? { return searchFields.wmf_firstElement(with: .placeholderValue, withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: shouldConvert) } @discardableResult func wmf_tapFirstCloseButton() -> Bool { return wmf_tapFirstButton(withTranslationIn: ["close-button-accessibility-label"]) } @discardableResult func wmf_tapFirstNavigationBarBackButton() -> Bool { let backButtonTapped = wmf_tapFirstButton(withTranslationIn: ["back", "back-button-accessibility-label", "home-title"]) guard !backButtonTapped else { return backButtonTapped } // Needed because if the title is long, the back button sometimes won't have text, as seen on https://stackoverflow.com/q/38595242/135557 if let button = navigationBars.buttons.element(boundBy: 0).wmf_waitUntilExists() { return button.wmf_tap() } else { return false } } @discardableResult func wmf_tapFirstCollectionViewCell() -> Bool { if let cell = collectionViews.children(matching: .cell).element(boundBy: 0).wmf_waitUntilExists() { return cell.wmf_tap() } else { return false } } @discardableResult func wmf_tapFirstTableViewCell() -> Bool { if let cell = tables.children(matching: .cell).element(boundBy: 0).wmf_waitUntilExists() { return cell.wmf_tap() } else { return false } } @discardableResult func wmf_scrollToTop() -> Bool { if let statusBar = statusBars.element(boundBy: 0).wmf_waitUntilExists() { let tapResult = statusBar.wmf_tap() return tapResult } else { return false } } func wmf_scrollElementToTop(element: XCUIElement, yOffset: CGFloat = 0.0) { let normalizedOffset = CGVector( dx: 0.5, dy: 0.0 /* 0.0 is important - in case only top of view is above bottom of screen! (if we were scrolling elements to bottom of screen this would need to be 1.0) */ ) let elementTopCoord = element.coordinate(withNormalizedOffset: normalizedOffset) elementTopCoord.press(forDuration: pressDuration, thenDragTo: coordinate(withNormalizedOffset: CGVector(dx: 0, dy: yOffset))) } func wmf_scrollDown(times: Int = 1, dragStartY: Double = 0.8) { for _ in 0 ..< times { // If dragStartY set to 1.0 it drags from very bottom of the screen which triggers an iPad task switcher and zooms out the app. So drag from a little above the very bottom - hence the 0.8 dragStartY default value. coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: dragStartY)).press(forDuration: pressDuration, thenDragTo: coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.0))) } } // Scrolls to first element for each ScrollItem key in single scrolling pass (i.e. without scrolling back to top between items). func wmf_scrollToFirstElements(matching type: XCUIElement.ElementType, yOffset: CGFloat, items: [ScrollItem], timeout seconds: Double = 180) { let start = Date() var keys = items.map {$0.key} scrollLoop: repeat { if let element = descendants(matching: type).wmf_firstElement(with: .label, withTranslationIn: keys, convertTranslationSubstitutionStringsToWildcards: true, timeout: 1) { if let item = items.first(where: {$0.predicate.evaluate(with: element.label)}) { wmf_scrollElementToTop(element: element, yOffset: yOffset) item.success(element) if let index = keys.firstIndex(of: item.key) { keys.remove(at: index) } continue scrollLoop // Need to skip `wmf_scrollDown()` because other elements may already be onscreen and we don't want to scroll any of them offscreen. This lets the next pass(es) through the loop catch 'em. } } wmf_scrollDown() } while (Date().timeIntervalSince(start) < seconds) && (!keys.isEmpty) } } private enum ElementPropertyType: String { case label case placeholderValue case `self` func predicate(for text: String) -> NSPredicate { return NSPredicate(format: "\(rawValue) ==[cd] %@", text) } func wildcardPredicate(for text: String) -> NSPredicate { var mutableText = text for i in 0...9 { mutableText = mutableText.replacingOccurrences(of: "%\(i)$@", with: "*") } mutableText = "*\(mutableText)*" return NSPredicate(format: "\(rawValue) like[cd] %@", mutableText) } func predicate(for texts: [String]) -> NSPredicate { return NSCompoundPredicate(orPredicateWithSubpredicates: texts.map {text in predicate(for: text)}) } func wildcardPredicate(for texts: [String]) -> NSPredicate { return NSCompoundPredicate(orPredicateWithSubpredicates: texts.map {text in wildcardPredicate(for: text)}) } } private extension XCUIElementQuery { func wmf_firstElement(with propertyType: ElementPropertyType, withTranslationIn keys: [String], convertTranslationSubstitutionStringsToWildcards shouldConvert: Bool = false, timeout: TimeInterval = 10) -> XCUIElement? { let translations = keys.map {key in WMFLocalizedString(key, value: "", comment: "")} // localization strings are copied into this scheme during a build phase: https://stackoverflow.com/a/38133902/135557 let predicate = shouldConvert ? propertyType.wildcardPredicate(for: translations) : propertyType.predicate(for: translations) return matching(predicate).element(boundBy: 0).wmf_waitUntilExists(timeout: timeout) } } struct ScrollItem { let key: String let success: (XCUIElement) -> Void let predicate: NSPredicate init(key: String, success: @escaping (XCUIElement) -> Void) { self.key = key self.success = success self.predicate = ElementPropertyType.`self`.wildcardPredicate(for: WMFLocalizedString(key, value: "", comment: "")) } } extension XCUIApplication { func dismissPopover() { otherElements["PopoverDismissRegion"].tap() } }