diff --git a/Readme.md b/Readme.md index 5ef9aa9..d49ce32 100644 --- a/Readme.md +++ b/Readme.md @@ -14,7 +14,7 @@ A `swift-webdriver` "Hello world" using `WinAppDriver` might look like this: let session = try Session( webDriver: WinAppDriver.start(), // Requires WinAppDriver to be installed on the machine desiredCapabilities: WinAppDriver.Capabilities.startApp(name: "notepad.exe")) -try session.requireElement(locator: .name("close")).click() +try session.findElement(locator: .name("close")).click() ``` To use `swift-webdriver` in your project, add a reference to it in your `Package.swift` file as follows: diff --git a/Sources/WebDriver/Element.swift b/Sources/WebDriver/Element.swift index 28c4cf5..0389138 100644 --- a/Sources/WebDriver/Element.swift +++ b/Sources/WebDriver/Element.swift @@ -86,28 +86,19 @@ public struct Element { /// - Parameter locator: The locator strategy to use. /// - Parameter waitTimeout: The amount of time to wait for element existence. Overrides the implicit wait timeout. /// - Returns: The element that was found, if any. - public func findElement(locator: ElementLocator, waitTimeout: TimeInterval? = nil) throws -> Element? { + @discardableResult // for use as an assertion + public func findElement(locator: ElementLocator, waitTimeout: TimeInterval? = nil) throws -> Element { try session.findElement(startingAt: self, locator: locator, waitTimeout: waitTimeout) } /// Search for elements using a given locator, starting from this element. /// - Parameter using: The locator strategy to use. /// - Parameter waitTimeout: The amount of time to wait for element existence. Overrides the implicit wait timeout. - /// - Returns: The elements that were found, if any. + /// - Returns: The elements that were found, or an empty array. public func findElements(locator: ElementLocator, waitTimeout: TimeInterval? = nil) throws -> [Element] { try session.findElements(startingAt: self, locator: locator, waitTimeout: waitTimeout) } - /// Finds an element using a given locator, starting from this element, and throwing upon failure. - /// - Parameter locator: The locator strategy to use. - /// - Parameter description: A human-readable description of the element, included in thrown errors. - /// - Parameter waitTimeout: The amount of time to wait for element existence. Overrides the implicit wait timeout. - /// - Returns: The element that was found. - @discardableResult // for use as an assertion - public func requireElement(locator: ElementLocator, description: String? = nil, waitTimeout: TimeInterval? = nil) throws -> Element { - try session.requireElement(startingAt: self, locator: locator, description: description, waitTimeout: waitTimeout) - } - /// Gets an attribute of this element. /// - Parameter name: the attribute name. /// - Returns: the attribute value string. diff --git a/Sources/WebDriver/ElementNotFoundError.swift b/Sources/WebDriver/NoSuchElementError.swift similarity index 58% rename from Sources/WebDriver/ElementNotFoundError.swift rename to Sources/WebDriver/NoSuchElementError.swift index c561f5a..9f538ea 100644 --- a/Sources/WebDriver/ElementNotFoundError.swift +++ b/Sources/WebDriver/NoSuchElementError.swift @@ -1,19 +1,20 @@ -public struct ElementNotFoundError: Error { +/// Thrown when findElement fails to locate an element. +public struct ElementNotFoundError: Error, CustomStringConvertible { /// The locator that was used to search for the element. public var locator: ElementLocator - /// A human-readable description of the element. - public var description: String? - /// The error that caused the element to not be found. public var sourceError: Error - public init(locator: ElementLocator, description: String? = nil, sourceError: Error) { + public init(locator: ElementLocator, sourceError: Error) { self.locator = locator - self.description = description self.sourceError = sourceError } /// The error response returned by the WebDriver server, if this was the source of the failure. public var errorResponse: ErrorResponse? { sourceError as? ErrorResponse } -} \ No newline at end of file + + public var description: String { + "Element not found using locator [\(locator.using)=\(locator.value)]: \(sourceError)" + } +} diff --git a/Sources/WebDriver/Poll.swift b/Sources/WebDriver/Poll.swift index 180a9f8..c397200 100644 --- a/Sources/WebDriver/Poll.swift +++ b/Sources/WebDriver/Poll.swift @@ -3,31 +3,32 @@ import struct Foundation.TimeInterval import struct Dispatch.DispatchTime /// Calls a closure repeatedly with exponential backoff until it reports success or a timeout elapses. -/// - Returns: The result from the last invocation of the closure. +/// Thrown errors bubble up immediately, returned errors allow retries. +/// - Returns: The successful value. internal func poll( timeout: TimeInterval, initialPeriod: TimeInterval = 0.001, - work: () throws -> PollResult) rethrows -> PollResult { + work: () throws -> Result) throws -> Value { let startTime = DispatchTime.now() - var result = try work() - if result.success { return result } - + var lastResult = try work() var period = initialPeriod while true { + guard case .failure = lastResult else { break } + // Check if we ran out of time and return the last result let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000 let remainingTime = timeout - elapsedTime - if remainingTime < 0 { return result } + if remainingTime < 0 { break } // Sleep for the next period and retry let sleepTime = min(period, remainingTime) Thread.sleep(forTimeInterval: sleepTime) - result = try work() - if result.success { return result } - + lastResult = try work() period *= 2 // Exponential backoff } + + return try lastResult.get() } /// Calls a closure repeatedly with exponential backoff until it reports success or a timeout elapses. @@ -35,21 +36,14 @@ internal func poll( internal func poll( timeout: TimeInterval, initialPeriod: TimeInterval = 0.001, - work: () throws -> Bool) rethrows -> Bool { - try poll(timeout: timeout, initialPeriod: initialPeriod) { - PollResult(value: Void(), success: try work()) - }.success -} - -internal struct PollResult { - let value: Value - let success: Bool - - static func success(_ value: Value) -> PollResult { - PollResult(value: value, success: true) + work: () throws -> Bool) throws -> Bool { + struct FalseError: Error {} + do { + try poll(timeout: timeout, initialPeriod: initialPeriod) { + try work() ? .success(()) : .failure(FalseError()) + } + return true + } catch _ as FalseError { + return false } - - static func failure(_ value: Value) -> PollResult { - PollResult(value: value, success: false) - } -} \ No newline at end of file +} diff --git a/Sources/WebDriver/Session.swift b/Sources/WebDriver/Session.swift index 3c1852a..9aa1081 100644 --- a/Sources/WebDriver/Session.swift +++ b/Sources/WebDriver/Session.swift @@ -141,44 +141,19 @@ public class Session { /// - Parameter locator: The locator strategy to use. /// - Parameter waitTimeout: The amount of time to wait for element existence. Overrides the implicit wait timeout. /// - Returns: The element that was found, if any. - public func findElement(locator: ElementLocator, waitTimeout: TimeInterval? = nil) throws -> Element? { + @discardableResult // for use as an assertion + public func findElement(locator: ElementLocator, waitTimeout: TimeInterval? = nil) throws -> Element { try findElement(startingAt: nil, locator: locator, waitTimeout: waitTimeout) } /// Finds elements by id, starting from the root. /// - Parameter locator: The locator strategy to use. /// - Parameter waitTimeout: The amount of time to wait for element existence. Overrides the implicit wait timeout. - /// - Returns: The elements that were found, if any. + /// - Returns: The elements that were found, or an empty array. public func findElements(locator: ElementLocator, waitTimeout: TimeInterval? = nil) throws -> [Element] { try findElements(startingAt: nil, locator: locator, waitTimeout: waitTimeout) } - /// Finds an element using a given locator, starting from the session root, and throwing upon failure. - /// - Parameter locator: The locator strategy to use. - /// - Parameter description: A human-readable description of the element, included in thrown errors. - /// - Parameter waitTimeout: The amount of time to wait for element existence. Overrides the implicit wait timeout. - /// - Returns: The element that was found. - @discardableResult // for use as an assertion - public func requireElement(locator: ElementLocator, description: String? = nil, waitTimeout: TimeInterval? = nil) throws -> Element { - try requireElement(startingAt: nil, locator: locator, description: description, waitTimeout: waitTimeout) - } - - internal func requireElement(startingAt subtreeRoot: Element?, locator: ElementLocator, description: String? = nil, waitTimeout: TimeInterval? = nil) throws -> Element { - let element: Element? - do { - element = try findElement(startingAt: subtreeRoot, locator: locator, waitTimeout: waitTimeout) - } catch let error { - throw ElementNotFoundError(locator: locator, description: description, sourceError: error) - } - - guard let element else { - let synthesizedResponse = ErrorResponse(status: .noSuchElement, value: .init(message: "Element not found")) - throw ElementNotFoundError(locator: locator, description: description, sourceError: synthesizedResponse) - } - - return element - } - /// Overrides the implicit wait timeout during a block of code. private func withImplicitWaitTimeout(_ value: TimeInterval?, _ block: () throws -> Result) rethrows -> Result { if let value, value != _implicitWaitTimeout { @@ -193,25 +168,26 @@ public class Session { } /// Common logic for `Session.findElement` and `Element.findElement`. - internal func findElement(startingAt subtreeRoot: Element?, locator: ElementLocator, waitTimeout: TimeInterval?) throws -> Element? { + internal func findElement(startingAt subtreeRoot: Element?, locator: ElementLocator, waitTimeout: TimeInterval?) throws -> Element { precondition(subtreeRoot == nil || subtreeRoot?.session === self) return try withImplicitWaitTimeout(waitTimeout) { let request = Requests.SessionElement(session: id, element: subtreeRoot?.id, locator: locator) - let elementId = try poll(timeout: emulateImplicitWait ? (waitTimeout ?? _implicitWaitTimeout) : TimeInterval.zero) { - let elementId: String? - do { - // Allow errors to bubble up unless they are specifically saying that the element was not found. - elementId = try webDriver.send(request).value.element - } catch let error as ErrorResponse where error.status == .noSuchElement { - elementId = nil + do { + return try poll(timeout: emulateImplicitWait ? (waitTimeout ?? _implicitWaitTimeout) : TimeInterval.zero) { + do { + // Allow errors to bubble up unless they are specifically saying that the element was not found. + let elementId = try webDriver.send(request).value.element + return .success(Element(session: self, id: elementId)) + } catch let error as ErrorResponse where error.status == .noSuchElement { + // Return instead of throwing to indicate that `poll` can retry as needed. + return .failure(error) + } } - - return PollResult(value: elementId, success: elementId != nil) - }.value - - return elementId.map { Element(session: self, id: $0) } + } catch { + throw ElementNotFoundError(locator: locator, sourceError: error) + } } } @@ -220,15 +196,20 @@ public class Session { try withImplicitWaitTimeout(waitTimeout) { let request = Requests.SessionElements(session: id, element: element?.id, locator: locator) - return try poll(timeout: emulateImplicitWait ? (waitTimeout ?? _implicitWaitTimeout) : TimeInterval.zero) { - do { - // Allow errors to bubble up unless they are specifically saying that the element was not found. - return PollResult.success(try webDriver.send(request).value.map { Element(session: self, id: $0.element) }) - } catch let error as ErrorResponse where error.status == .noSuchElement { - // Follow the WebDriver spec and keep polling if no elements are found - return PollResult.failure([]) + do { + return try poll(timeout: emulateImplicitWait ? (waitTimeout ?? _implicitWaitTimeout) : TimeInterval.zero) { + do { + // Allow errors to bubble up unless they are specifically saying that the element was not found. + return .success(try webDriver.send(request).value.map { Element(session: self, id: $0.element) }) + } catch let error as ErrorResponse where error.status == .noSuchElement { + // Follow the WebDriver spec and keep polling if no elements are found. + // Return instead of throwing to indicate that `poll` can retry as needed. + return .failure(error) + } } - }.value + } catch let error as ErrorResponse where error.status == .noSuchElement { + return [] + } } } @@ -378,17 +359,16 @@ public class Session { /// Sends an interaction request, retrying until it is conclusive or the timeout elapses. internal func sendInteraction(_ request: Req, retryTimeout: TimeInterval? = nil) throws where Req.Response == CodableNone { - let result = try poll(timeout: retryTimeout ?? implicitInteractionRetryTimeout) { + try poll(timeout: retryTimeout ?? implicitInteractionRetryTimeout) { do { // Immediately bubble most failures, only retry if inconclusive. try webDriver.send(request) - return PollResult.success(nil as ErrorResponse?) + return .success(()) } catch let error as ErrorResponse where webDriver.isInconclusiveInteraction(error: error.status) { - return PollResult.failure(error) + // Return instead of throwing to indicate that `poll` can retry as needed. + return .failure(error) } } - - if let error = result.value { throw error } } deinit { diff --git a/Tests/UnitTests/APIToRequestMappingTests.swift b/Tests/UnitTests/APIToRequestMappingTests.swift index 3349fc2..efc17cd 100644 --- a/Tests/UnitTests/APIToRequestMappingTests.swift +++ b/Tests/UnitTests/APIToRequestMappingTests.swift @@ -51,7 +51,7 @@ class APIToRequestMappingTests: XCTestCase { XCTAssertEqual($0.value, "myElement.name") return ResponseWithValue(.init(element: "myElement")) } - try session.requireElement(locator: .name("myElement.name")) + try session.findElement(locator: .name("myElement.name")) mockWebDriver.expect(path: "session/mySession/element/active", method: .post, type: Requests.SessionActiveElement.self) { ResponseWithValue(.init(element: "myElement")) diff --git a/Tests/WinAppDriverTests/MSInfo32App.swift b/Tests/WinAppDriverTests/MSInfo32App.swift index 0a4bf5d..eb0db7e 100644 --- a/Tests/WinAppDriverTests/MSInfo32App.swift +++ b/Tests/WinAppDriverTests/MSInfo32App.swift @@ -13,28 +13,28 @@ class MSInfo32App { } private lazy var _maximizeButton = Result { - try session.requireElement(locator: .name("Maximize"), description: "Maximize window button") + try session.findElement(locator: .name("Maximize")) } var maximizeButton: Element { get throws { try _maximizeButton.get() } } private lazy var _systemSummaryTree = Result { - try session.requireElement(locator: .accessibilityId("201"), description: "System summary tree control") + try session.findElement(locator: .accessibilityId("201")) } var systemSummaryTree: Element { get throws { try _systemSummaryTree.get() } } private lazy var _findWhatEditBox = Result { - try session.requireElement(locator: .accessibilityId("204"), description: "'Find what' edit box") + try session.findElement(locator: .accessibilityId("204")) } var findWhatEditBox: Element { get throws { try _findWhatEditBox.get() } } private lazy var _searchSelectedCategoryOnlyCheckbox = Result { - try session.requireElement(locator: .accessibilityId("206"), description: "'Search selected category only' checkbox") + try session.findElement(locator: .accessibilityId("206")) } var searchSelectedCategoryOnlyCheckbox: Element { get throws { try _searchSelectedCategoryOnlyCheckbox.get() } @@ -48,4 +48,4 @@ class MSInfo32App { var listView: Element { get throws { try _listView.get() } } -} \ No newline at end of file +} diff --git a/Tests/WinAppDriverTests/RequestsTests.swift b/Tests/WinAppDriverTests/RequestsTests.swift index 2c72220..1d7b577 100644 --- a/Tests/WinAppDriverTests/RequestsTests.swift +++ b/Tests/WinAppDriverTests/RequestsTests.swift @@ -78,14 +78,15 @@ class RequestsTests: XCTestCase { // ☃: Unicode BMP character let str = "kKł☃" try app.findWhatEditBox.sendKeys(Keys.text(str, typingStrategy: .windowsKeyboardAgnostic)) + // Normally we should be able to read the text back immediately, // but the MSInfo32 "Find what" edit box seems to queue events // such that WinAppDriver returns before they are fully processed. - XCTAssertEqual( - try poll(timeout: 0.5) { - let text = try app.findWhatEditBox.text - return PollResult(value: text, success: text == str) - }.value, str) + struct UnexpectedText: Error { var text: String } + _ = try poll(timeout: 0.5) { + let text = try app.findWhatEditBox.text + return text == str ? .success(()) : .failure(UnexpectedText(text: text)) + } } func testSendKeysWithAcceleratorsGivesFocus() throws {