Skip to content

Commit

Permalink
Make Session/Element.findElement throwing (#158)
Browse files Browse the repository at this point in the history
New approach from #157 : Other Selenium bindings (Java, C#) have a
throwing `findElement` method, so do the same for Swift.

Also refactors `poll` to remove the `PollResult` type in favor of having
the closure return a `Result<Value, Error>`. A returned error allows a
retry whereas a thrown error stops the polling.
  • Loading branch information
tristanlabelle authored Oct 23, 2024
1 parent f578048 commit 1f70708
Show file tree
Hide file tree
Showing 11 changed files with 110 additions and 77 deletions.
4 changes: 2 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Sources/WebDriver/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ add_library(WebDriver
Keys.swift
Location.swift
MouseButton.swift
NoSuchElementError.swift
Poll.swift
Request.swift
Requests.swift
Expand Down
5 changes: 3 additions & 2 deletions Sources/WebDriver/Element.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/WebDriver/ErrorResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/WebDriver/NoSuchElementError.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
46 changes: 20 additions & 26 deletions Sources/WebDriver/Poll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,47 @@ 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<Value>(
timeout: TimeInterval,
initialPeriod: TimeInterval = 0.001,
work: () throws -> PollResult<Value>) rethrows -> PollResult<Value> {
work: () throws -> Result<Value, Error>) 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.
/// - Returns: Whether the closure reported success within the expected time.
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<Value> {
let value: Value
let success: Bool

static func success(_ value: Value) -> PollResult<Value> {
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<Value> {
PollResult(value: value, success: false)
}
}
}
66 changes: 36 additions & 30 deletions Sources/WebDriver/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
}
}

Expand All @@ -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 []
}
}
}

Expand Down Expand Up @@ -352,17 +359,16 @@ public class Session {

/// Sends an interaction request, retrying until it is conclusive or the timeout elapses.
internal func sendInteraction<Req: Request>(_ 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 {
Expand Down
2 changes: 1 addition & 1 deletion Tests/UnitTests/APIToRequestMappingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
10 changes: 5 additions & 5 deletions Tests/WinAppDriverTests/MSInfo32App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -48,4 +48,4 @@ class MSInfo32App {
var listView: Element {
get throws { try _listView.get() }
}
}
}
11 changes: 6 additions & 5 deletions Tests/WinAppDriverTests/RequestsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 15 additions & 5 deletions Tests/WinAppDriverTests/TimeoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
}

0 comments on commit 1f70708

Please sign in to comment.