This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended. To summarise this work: - [x] created a new **Xcode** project; - [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project; - [x] created the `Locations` app and also, its `Libraries` package; - [x] created the `Shared` package to share dependencies between the apps; - [x] added a `Makefile` file and implemented some **environment** and **help** commands. Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Reviewed-on: rock-n-code/deep-linking-assignment#1
196 lines
9.4 KiB
Swift
196 lines
9.4 KiB
Swift
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()
|
|
}
|
|
}
|