diff --git a/Readme.md b/Readme.md index 1897e1d..d49ce32 100644 --- a/Readme.md +++ b/Readme.md @@ -11,10 +11,10 @@ A Swift library for UI automation of apps and browsers via communication with [W A `swift-webdriver` "Hello world" using `WinAppDriver` might look like this: ```swift -let session = Session( +let session = try Session( webDriver: WinAppDriver.start(), // Requires WinAppDriver to be installed on the machine desiredCapabilities: WinAppDriver.Capabilities.startApp(name: "notepad.exe")) -session.findElement(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/CMakeLists.txt b/Sources/WebDriver/CMakeLists.txt index bde9e05..4d5922b 100644 --- a/Sources/WebDriver/CMakeLists.txt +++ b/Sources/WebDriver/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(WebDriver Keys.swift Location.swift MouseButton.swift + NoSuchElementError.swift Poll.swift Request.swift Requests.swift diff --git a/Sources/WebDriver/Element.swift b/Sources/WebDriver/Element.swift index 05205e5..0389138 100644 --- a/Sources/WebDriver/Element.swift +++ b/Sources/WebDriver/Element.swift @@ -86,14 +86,15 @@ 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) } diff --git a/Sources/WebDriver/ErrorResponse.swift b/Sources/WebDriver/ErrorResponse.swift index 8d24380..7db02c5 100644 --- a/Sources/WebDriver/ErrorResponse.swift +++ b/Sources/WebDriver/ErrorResponse.swift @@ -38,7 +38,7 @@ public struct ErrorResponse: Codable, Error { } public struct Value: Codable { - public var error: String + public var error: String? public var message: String public var stacktrace: String? } diff --git a/Sources/WebDriver/NoSuchElementError.swift b/Sources/WebDriver/NoSuchElementError.swift new file mode 100644 index 0000000..9f538ea --- /dev/null +++ b/Sources/WebDriver/NoSuchElementError.swift @@ -0,0 +1,20 @@ +/// 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 + + /// The error that caused the element to not be found. + public var sourceError: Error + + public init(locator: ElementLocator, sourceError: Error) { + self.locator = locator + 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 } + + 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 621504f..9aa1081 100644 --- a/Sources/WebDriver/Session.swift +++ b/Sources/WebDriver/Session.swift @@ -141,14 +141,15 @@ 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) } @@ -167,25 +168,26 @@ public class Session { } /// Common logic for `Session.findElement` and `Element.findElement`. - internal func findElement(startingAt element: Element?, locator: ElementLocator, waitTimeout: TimeInterval?) throws -> Element? { - precondition(element == nil || element?.session === self) + 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: element?.id, locator: locator) + 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) + } } } @@ -194,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 [] + } } } @@ -352,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 65d0384..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")) } - XCTAssertNotNil(try session.findElement(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 00e1a98..eb0db7e 100644 --- a/Tests/WinAppDriverTests/MSInfo32App.swift +++ b/Tests/WinAppDriverTests/MSInfo32App.swift @@ -13,28 +13,28 @@ class MSInfo32App { } private lazy var _maximizeButton = Result { - try XCTUnwrap(session.findElement(locator: .name("Maximize")), "Maximize button not found") + try session.findElement(locator: .name("Maximize")) } var maximizeButton: Element { get throws { try _maximizeButton.get() } } private lazy var _systemSummaryTree = Result { - try XCTUnwrap(session.findElement(locator: .accessibilityId("201")), "System summary tree control not found") + try session.findElement(locator: .accessibilityId("201")) } var systemSummaryTree: Element { get throws { try _systemSummaryTree.get() } } private lazy var _findWhatEditBox = Result { - try XCTUnwrap(session.findElement(locator: .accessibilityId("204")), "'Find what' edit box not found") + try session.findElement(locator: .accessibilityId("204")) } var findWhatEditBox: Element { get throws { try _findWhatEditBox.get() } } private lazy var _searchSelectedCategoryOnlyCheckbox = Result { - try XCTUnwrap(session.findElement(locator: .accessibilityId("206")), "'Search selected category only' checkbox not found") + 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 { diff --git a/Tests/WinAppDriverTests/TimeoutTests.swift b/Tests/WinAppDriverTests/TimeoutTests.swift index 6c32b0e..716cda5 100644 --- a/Tests/WinAppDriverTests/TimeoutTests.swift +++ b/Tests/WinAppDriverTests/TimeoutTests.swift @@ -27,21 +27,31 @@ class TimeoutTests: XCTestCase { return try Session(webDriver: winAppDriver, desiredCapabilities: capabilities) } - static func time(_ callback: () throws -> Void) rethrows -> Double { + static func measureTime(_ callback: () throws -> Void) rethrows -> Double { let before = DispatchTime.now() try callback() let after = DispatchTime.now() return Double(after.uptimeNanoseconds - before.uptimeNanoseconds) / 1_000_000_000 } + static func measureNoSuchElementTime(_ session: Session) -> Double { + measureTime { + do { + try session.findElement(locator: .accessibilityId("IdThatDoesNotExist")) + XCTFail("Expected a no such element error") + } + catch {} + } + } + public func testWebDriverImplicitWait() throws { let session = try startApp() session.implicitWaitTimeout = 1 - XCTAssert(try Self.time({ _ = try session.findElement(locator: .accessibilityId("IdThatDoesNotExist")) }) > 0.5) + XCTAssertGreaterThan(Self.measureNoSuchElementTime(session), 0.5) session.implicitWaitTimeout = 0 - XCTAssert(try Self.time({ _ = try session.findElement(locator: .accessibilityId("IdThatDoesNotExist")) }) < 0.5) + XCTAssertLessThan(Self.measureNoSuchElementTime(session), 0.5) XCTAssert(!session.emulateImplicitWait) } @@ -52,9 +62,9 @@ class TimeoutTests: XCTestCase { // Test library timeout implementation session.emulateImplicitWait = true session.implicitWaitTimeout = 1 - XCTAssert(try Self.time({ _ = try session.findElement(locator: .accessibilityId("IdThatDoesNotExist")) }) > 0.5) + XCTAssertGreaterThan(Self.measureNoSuchElementTime(session), 0.5) session.implicitWaitTimeout = 0 - XCTAssert(try Self.time({ _ = try session.findElement(locator: .accessibilityId("IdThatDoesNotExist")) }) < 0.5) + XCTAssertLessThan(Self.measureNoSuchElementTime(session), 0.5) } }