Skip to content

Commit

Permalink
Make findElement throwing
Browse files Browse the repository at this point in the history
  • Loading branch information
tristanlabelle committed Oct 23, 2024
1 parent 62e940c commit 5adfb2d
Show file tree
Hide file tree
Showing 8 changed files with 78 additions and 111 deletions.
2 changes: 1 addition & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 3 additions & 12 deletions Sources/WebDriver/Element.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}

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)
}
}
}
88 changes: 34 additions & 54 deletions Sources/WebDriver/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result>(_ value: TimeInterval?, _ block: () throws -> Result) rethrows -> Result {
if let value, value != _implicitWaitTimeout {
Expand All @@ -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)
}
}
}

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

Expand Down Expand Up @@ -378,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"))
}
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"))
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 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() }
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

0 comments on commit 5adfb2d

Please sign in to comment.