Combine the WebDriver setup process into WebDriverService and make it easier to reuse (#474)
This commit is contained in:
parent
6cc68d3ba8
commit
f7d0b5640f
|
@ -113,7 +113,7 @@ let package = Package(
|
|||
.product(name: "NIO", package: "swift-nio"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
"CartonHelpers",
|
||||
"WebDriverClient",
|
||||
"WebDriver",
|
||||
"WasmTransformer",
|
||||
],
|
||||
exclude: ["Utilities/README.md"]
|
||||
|
@ -149,7 +149,13 @@ let package = Package(
|
|||
name: "CartonCore",
|
||||
exclude: ["README.md"]
|
||||
),
|
||||
.target(name: "WebDriverClient", dependencies: []),
|
||||
.target(
|
||||
name: "WebDriver",
|
||||
dependencies: [
|
||||
.product(name: "NIO", package: "swift-nio"),
|
||||
"CartonHelpers"
|
||||
]
|
||||
),
|
||||
// This target is used only for release automation tasks and
|
||||
// should not be installed by `carton` users.
|
||||
.executableTarget(
|
||||
|
@ -176,6 +182,6 @@ let package = Package(
|
|||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
]
|
||||
),
|
||||
.testTarget(name: "WebDriverClientTests", dependencies: ["WebDriverClient"]),
|
||||
.testTarget(name: "WebDriverTests", dependencies: ["WebDriver"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -17,7 +17,7 @@ import CartonKit
|
|||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import WebDriverClient
|
||||
import WebDriver
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
|
@ -71,86 +71,6 @@ struct BrowserTestRunner: TestRunner {
|
|||
self.terminal = terminal
|
||||
}
|
||||
|
||||
typealias Disposer = () -> Void
|
||||
|
||||
func findAvailablePort() async throws -> SocketAddress {
|
||||
let bootstrap = ServerBootstrap(group: eventLoopGroup)
|
||||
let address = try SocketAddress.makeAddressResolvingHost("127.0.0.1", port: 0)
|
||||
let channel = try await bootstrap.bind(to: address).get()
|
||||
let localAddr = channel.localAddress!
|
||||
try await channel.close()
|
||||
return localAddr
|
||||
}
|
||||
|
||||
func launchDriver(executablePath: String) async throws -> (URL, Disposer) {
|
||||
let address = try await findAvailablePort()
|
||||
let process = Process(arguments: [
|
||||
executablePath, "--port=\(address.port!)",
|
||||
])
|
||||
terminal.logLookup("Launch WebDriver executable: ", executablePath)
|
||||
try process.launch()
|
||||
let disposer = { process.signal(SIGKILL) }
|
||||
return (URL(string: "http://\(address.ipAddress!):\(address.port!)")!, disposer)
|
||||
}
|
||||
|
||||
func selectWebDriver() async throws -> (URL, Disposer) {
|
||||
let strategies: [() async throws -> (URL, Disposer)?] = [
|
||||
{
|
||||
terminal.logLookup("- checking WebDriver endpoint: ", "WEBDRIVER_REMOTE_URL")
|
||||
guard let value = ProcessInfo.processInfo.environment["WEBDRIVER_REMOTE_URL"] else {
|
||||
return nil
|
||||
}
|
||||
guard let url = URL(string: value) else {
|
||||
throw BrowserTestRunnerError.invalidRemoteURL(value)
|
||||
}
|
||||
return (url, {})
|
||||
},
|
||||
{
|
||||
terminal.logLookup("- checking WebDriver executable: ", "WEBDRIVER_PATH")
|
||||
guard let executable = ProcessInfo.processInfo.environment["WEBDRIVER_PATH"] else {
|
||||
return nil
|
||||
}
|
||||
let (url, disposer) = try await launchDriver(executablePath: executable)
|
||||
return (url, disposer)
|
||||
},
|
||||
{
|
||||
let driverCandidates = [
|
||||
"chromedriver", "geckodriver", "safaridriver", "msedgedriver",
|
||||
]
|
||||
terminal.logLookup(
|
||||
"- checking WebDriver executable in PATH: ", driverCandidates.joined(separator: ", "))
|
||||
guard let found = driverCandidates.lazy.compactMap({ Process.findExecutable($0) }).first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return try await launchDriver(executablePath: found.pathString)
|
||||
},
|
||||
]
|
||||
for strategy in strategies {
|
||||
if let (url, disposer) = try await strategy() {
|
||||
return (url, disposer)
|
||||
}
|
||||
}
|
||||
throw BrowserTestRunnerError.failedToFindWebDriver
|
||||
}
|
||||
|
||||
func makeClient(endpoint: URL) async throws -> WebDriverClient {
|
||||
let maxRetries = 3
|
||||
var retries = 0
|
||||
while true {
|
||||
do {
|
||||
return try await WebDriverClient.newSession(
|
||||
endpoint: endpoint, httpClient: URLSession.shared)
|
||||
} catch {
|
||||
if retries >= maxRetries {
|
||||
throw error
|
||||
}
|
||||
retries += 1
|
||||
try await _Concurrency.Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let server = try await Server(
|
||||
.init(
|
||||
|
@ -169,11 +89,11 @@ struct BrowserTestRunner: TestRunner {
|
|||
var disposer: () async throws -> Void = {}
|
||||
do {
|
||||
if headless {
|
||||
let (endpoint, clientDisposer) = try await selectWebDriver()
|
||||
let client = try await makeClient(endpoint: endpoint)
|
||||
let webDriver = try await WebDriverServices.find(terminal: terminal)
|
||||
let client = try await webDriver.client()
|
||||
disposer = {
|
||||
try await client.closeSession()
|
||||
clientDisposer()
|
||||
webDriver.dispose()
|
||||
}
|
||||
try await client.goto(url: localURL)
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
public func withRetry<R>(
|
||||
maxAttempts: Int,
|
||||
initialDelay: Duration,
|
||||
retryInterval: Duration,
|
||||
body: () async throws -> R
|
||||
) async throws -> R {
|
||||
try await Task.sleep(for: initialDelay)
|
||||
|
||||
var attempt = 0
|
||||
while true {
|
||||
attempt += 1
|
||||
do {
|
||||
return try await body()
|
||||
} catch {
|
||||
if attempt < maxAttempts {
|
||||
print("attempt \(attempt)/\(maxAttempts) failed: \(error), retrying in \(retryInterval)...")
|
||||
|
||||
try await Task.sleep(for: retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import CartonHelpers
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
public struct CommandWebDriverService: WebDriverService {
|
||||
private static func findAvailablePort() async throws -> SocketAddress {
|
||||
let bootstrap = ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup)
|
||||
let address = try SocketAddress.makeAddressResolvingHost("127.0.0.1", port: 0)
|
||||
let channel = try await bootstrap.bind(to: address).get()
|
||||
let localAddr = channel.localAddress!
|
||||
try await channel.close()
|
||||
return localAddr
|
||||
}
|
||||
|
||||
private static func launchDriver(
|
||||
terminal: InteractiveWriter,
|
||||
executablePath: String
|
||||
) async throws -> (URL, CartonHelpers.Process) {
|
||||
let address = try await findAvailablePort()
|
||||
let process = CartonHelpers.Process(arguments: [
|
||||
executablePath, "--port=\(address.port!)",
|
||||
])
|
||||
terminal.logLookup("Launch WebDriver executable: ", executablePath)
|
||||
try process.launch()
|
||||
let url = URL(string: "http://\(address.ipAddress!):\(address.port!)")!
|
||||
return (url, process)
|
||||
}
|
||||
|
||||
public static func findFromEnvironment(
|
||||
terminal: CartonHelpers.InteractiveWriter
|
||||
) async throws -> CommandWebDriverService? {
|
||||
terminal.logLookup("- checking WebDriver executable: ", "WEBDRIVER_PATH")
|
||||
guard let executable = ProcessInfo.processInfo.environment["WEBDRIVER_PATH"] else {
|
||||
return nil
|
||||
}
|
||||
let (endpoint, process) = try await launchDriver(
|
||||
terminal: terminal, executablePath: executable
|
||||
)
|
||||
return CommandWebDriverService(endpoint: endpoint, process: process)
|
||||
}
|
||||
|
||||
public static func findFromPath(
|
||||
terminal: CartonHelpers.InteractiveWriter
|
||||
) async throws -> CommandWebDriverService? {
|
||||
let driverCandidates = [
|
||||
"chromedriver", "geckodriver", "safaridriver", "msedgedriver",
|
||||
]
|
||||
terminal.logLookup(
|
||||
"- checking WebDriver executable in PATH: ", driverCandidates.joined(separator: ", "))
|
||||
guard let found = driverCandidates.lazy
|
||||
.compactMap({ CartonHelpers.Process.findExecutable($0) }).first else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
let (endpoint, process) = try await launchDriver(
|
||||
terminal: terminal, executablePath: found.pathString
|
||||
)
|
||||
return CommandWebDriverService(endpoint: endpoint, process: process)
|
||||
}
|
||||
|
||||
public static func find(
|
||||
terminal: CartonHelpers.InteractiveWriter
|
||||
) async throws -> CommandWebDriverService? {
|
||||
if let driver = try await findFromEnvironment(terminal: terminal) {
|
||||
return driver
|
||||
}
|
||||
|
||||
if let driver = try await findFromPath(terminal: terminal) {
|
||||
return driver
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public init(
|
||||
endpoint: URL,
|
||||
process: CartonHelpers.Process
|
||||
) {
|
||||
self.endpoint = endpoint
|
||||
self.process = process
|
||||
}
|
||||
|
||||
public var endpoint: URL
|
||||
public var process: CartonHelpers.Process
|
||||
|
||||
public func dispose() {
|
||||
process.signal(SIGKILL)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public struct CurlWebDriverHTTPClient: WebDriverHTTPClient {
|
||||
public init(cliPath: URL) {
|
||||
self.cliPath = cliPath
|
||||
}
|
||||
|
||||
public var cliPath: URL
|
||||
|
||||
public static func find() -> CurlWebDriverHTTPClient? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil }
|
||||
#if os(Windows)
|
||||
let pathSeparator: Character = ";"
|
||||
#else
|
||||
let pathSeparator: Character = ":"
|
||||
#endif
|
||||
for pathEntry in path.split(separator: pathSeparator) {
|
||||
let candidate = URL(fileURLWithPath: String(pathEntry)).appendingPathComponent("curl")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return CurlWebDriverHTTPClient(cliPath: candidate)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func data(for request: URLRequest) async throws -> Data {
|
||||
guard let url = request.url?.absoluteString else {
|
||||
preconditionFailure()
|
||||
}
|
||||
let process = Process()
|
||||
process.executableURL = cliPath
|
||||
process.arguments = [
|
||||
url, "-X", request.httpMethod ?? "GET", "--silent", "--fail-with-body", "--data-binary", "@-"
|
||||
]
|
||||
let stdout = Pipe()
|
||||
let stdin = Pipe()
|
||||
process.standardOutput = stdout
|
||||
process.standardInput = stdin
|
||||
if let httpBody = request.httpBody {
|
||||
try stdin.fileHandleForWriting.write(contentsOf: httpBody)
|
||||
}
|
||||
try stdin.fileHandleForWriting.close()
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let responseBody = try stdout.fileHandleForReading.readToEnd()
|
||||
guard process.terminationStatus == 0 else {
|
||||
let body: String? = responseBody.map { String(decoding: $0, as: UTF8.self) }
|
||||
|
||||
throw WebDriverError.curlError(
|
||||
path: cliPath,
|
||||
status: process.terminationStatus,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
return responseBody ?? Data()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import CartonHelpers
|
||||
import Foundation
|
||||
|
||||
public struct RemoteWebDriverService: WebDriverService {
|
||||
public static func find(
|
||||
terminal: InteractiveWriter
|
||||
) async throws -> RemoteWebDriverService? {
|
||||
terminal.logLookup("- checking WebDriver endpoint: ", "WEBDRIVER_REMOTE_URL")
|
||||
guard let value = ProcessInfo.processInfo.environment["WEBDRIVER_REMOTE_URL"] else {
|
||||
return nil
|
||||
}
|
||||
guard let endporint = URL(string: value) else {
|
||||
throw WebDriverError.invalidRemoteURL(value)
|
||||
}
|
||||
return RemoteWebDriverService(endpoint: endporint)
|
||||
}
|
||||
|
||||
public init(endpoint: URL) {
|
||||
self.endpoint = endpoint
|
||||
}
|
||||
|
||||
public var endpoint: URL
|
||||
|
||||
public func dispose() {}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
|
||||
import FoundationNetworking
|
||||
|
||||
/// Until we get "async" implementations of URLSession in corelibs-foundation, we use our own polyfill.
|
||||
extension URLSession {
|
||||
public func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let task = self.dataTask(with: request) { (data, response, error) in
|
||||
guard let data = data, let response = response else {
|
||||
let error = error ?? URLError(.badServerResponse)
|
||||
return continuation.resume(throwing: error)
|
||||
}
|
||||
continuation.resume(returning: (data, response))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,40 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
#else
|
||||
|
||||
// Due to a broken URLSession in swift-corelibs-foundation, this class cannot be used on Linux.
|
||||
public struct URLSessionWebDriverHTTPClient: WebDriverHTTPClient {
|
||||
public init(session: URLSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public var session: URLSession
|
||||
|
||||
public func data(for request: URLRequest) async throws -> Data {
|
||||
let (data, httpResponse) = try await session.data(for: request)
|
||||
guard let httpResponse = httpResponse as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode
|
||||
else {
|
||||
throw WebDriverError.httpError(
|
||||
"\(request.httpMethod ?? "GET") \(request.url.debugDescription) failed"
|
||||
)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -15,31 +15,11 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
|
||||
/// Until we get "async" implementations of URLSession in corelibs-foundation, we use our own polyfill.
|
||||
extension URLSession {
|
||||
public func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let task = self.dataTask(with: request) { (data, response, error) in
|
||||
guard let data = data, let response = response else {
|
||||
let error = error ?? URLError(.badServerResponse)
|
||||
return continuation.resume(throwing: error)
|
||||
}
|
||||
continuation.resume(returning: (data, response))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public enum WebDriverError: Error {
|
||||
case httpError(String)
|
||||
}
|
||||
|
||||
public struct WebDriverClient {
|
||||
private let client: WebDriverHTTPClient
|
||||
private let client: any WebDriverHTTPClient
|
||||
let driverEndpoint: URL
|
||||
let sessionId: String
|
||||
|
||||
|
@ -71,8 +51,9 @@ public struct WebDriverClient {
|
|||
"""#
|
||||
|
||||
public static func newSession(
|
||||
endpoint: URL, body: String = defaultSessionRequestBody,
|
||||
httpClient: URLSession
|
||||
endpoint: URL,
|
||||
body: String = defaultSessionRequestBody,
|
||||
httpClient: any WebDriverHTTPClient
|
||||
) async throws -> WebDriverClient {
|
||||
struct Response: Decodable {
|
||||
let sessionId: String
|
||||
|
@ -81,7 +62,7 @@ public struct WebDriverClient {
|
|||
let capabilities: [String: String] = [:]
|
||||
let desiredCapabilities: [String: String] = [:]
|
||||
}
|
||||
let httpClient: WebDriverHTTPClient = Curl.findExecutable() ?? httpClient
|
||||
|
||||
var request = URLRequest(url: endpoint.appendingPathComponent("session"))
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
|
@ -127,69 +108,3 @@ public struct WebDriverClient {
|
|||
_ = try await client.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private protocol WebDriverHTTPClient {
|
||||
func data(for request: URLRequest) async throws -> Data
|
||||
}
|
||||
|
||||
extension URLSession: WebDriverHTTPClient {
|
||||
func data(for request: URLRequest) async throws -> Data {
|
||||
let (data, httpResponse) = try await self.data(for: request)
|
||||
guard let httpResponse = httpResponse as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode
|
||||
else {
|
||||
throw WebDriverError.httpError(
|
||||
"\(request.httpMethod ?? "GET") \(request.url.debugDescription) failed"
|
||||
)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private struct Curl: WebDriverHTTPClient {
|
||||
let cliPath: URL
|
||||
|
||||
static func findExecutable() -> Curl? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil }
|
||||
#if os(Windows)
|
||||
let pathSeparator: Character = ";"
|
||||
#else
|
||||
let pathSeparator: Character = ":"
|
||||
#endif
|
||||
for pathEntry in path.split(separator: pathSeparator) {
|
||||
let candidate = URL(fileURLWithPath: String(pathEntry)).appendingPathComponent("curl")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return Curl(cliPath: candidate)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> Data {
|
||||
guard let url = request.url?.absoluteString else {
|
||||
preconditionFailure()
|
||||
}
|
||||
let process = Process()
|
||||
process.executableURL = cliPath
|
||||
process.arguments = [
|
||||
url, "-X", request.httpMethod ?? "GET", "--silent", "--fail-with-body", "--data-binary", "@-"
|
||||
]
|
||||
let stdout = Pipe()
|
||||
let stdin = Pipe()
|
||||
process.standardOutput = stdout
|
||||
process.standardInput = stdin
|
||||
if let httpBody = request.httpBody {
|
||||
try stdin.fileHandleForWriting.write(contentsOf: httpBody)
|
||||
}
|
||||
try stdin.fileHandleForWriting.close()
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let responseBody = try stdout.fileHandleForReading.readToEnd()
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw WebDriverError.httpError(
|
||||
responseBody.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
)
|
||||
}
|
||||
return responseBody ?? Data()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum WebDriverError: Error & CustomStringConvertible {
|
||||
case invalidRemoteURL(String)
|
||||
case failedToFindWebDriver
|
||||
case failedToFindHTTPClient
|
||||
case curlError(path: URL, status: Int32, body: String?)
|
||||
case httpError(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalidRemoteURL(let url): return "invalid remote webdriver URL: \(url)"
|
||||
case .curlError(path: let path, status: let status, body: let body):
|
||||
var lines: [String] = [
|
||||
"\(path.path) failed with status \(status)."
|
||||
]
|
||||
|
||||
if let body {
|
||||
lines += [
|
||||
"body:", body
|
||||
]
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
case .failedToFindWebDriver:
|
||||
return """
|
||||
Failed to find WebDriver executable or remote URL to a running driver process.
|
||||
Please make sure that you are satisfied with one of the followings (in order of priority)
|
||||
1. Set `WEBDRIVER_REMOTE_URL` with the address of remote WebDriver like `WEBDRIVER_REMOTE_URL=http://localhost:9515`.
|
||||
2. Set `WEBDRIVER_PATH` with the path to your WebDriver executable.
|
||||
3. `chromedriver`, `geckodriver`, `safaridriver`, or `msedgedriver` has been installed in `PATH`
|
||||
"""
|
||||
case .failedToFindHTTPClient:
|
||||
return """
|
||||
The HTTPClient for use with WebDriver could not be found.
|
||||
On Linux, please ensure that curl is installed.
|
||||
On Mac, URLSession can be used, so this error should not appear.
|
||||
If this error is displayed, an unknown bug may have occurred.
|
||||
"""
|
||||
case .httpError(let string): return "http error: \(string)"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public protocol WebDriverHTTPClient {
|
||||
func data(for request: URLRequest) async throws -> Data
|
||||
}
|
||||
|
||||
public enum WebDriverHTTPClients {
|
||||
public static func find() throws -> any WebDriverHTTPClient {
|
||||
if let curl = CurlWebDriverHTTPClient.find() {
|
||||
return curl
|
||||
}
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
throw WebDriverError.failedToFindHTTPClient
|
||||
#else
|
||||
return URLSessionWebDriverHTTPClient(session: .shared)
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import CartonHelpers
|
||||
import Foundation
|
||||
|
||||
public protocol WebDriverService {
|
||||
static func find(
|
||||
terminal: InteractiveWriter
|
||||
) async throws -> Self?
|
||||
|
||||
func dispose()
|
||||
|
||||
var endpoint: URL { get }
|
||||
}
|
||||
|
||||
extension WebDriverService {
|
||||
public func client(
|
||||
httpClient: (any WebDriverHTTPClient)? = nil
|
||||
) async throws -> WebDriverClient {
|
||||
let httpClient = try httpClient ?? WebDriverHTTPClients.find()
|
||||
|
||||
return try await withRetry(
|
||||
maxAttempts: 5,
|
||||
initialDelay: .seconds(3),
|
||||
retryInterval: .seconds(10)
|
||||
) {
|
||||
try await WebDriverClient.newSession(
|
||||
endpoint: endpoint,
|
||||
httpClient: httpClient
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum WebDriverServices {
|
||||
public static func find(
|
||||
terminal: InteractiveWriter
|
||||
) async throws -> any WebDriverService {
|
||||
if let service = try await RemoteWebDriverService.find(terminal: terminal) {
|
||||
return service
|
||||
}
|
||||
|
||||
if let service = try await CommandWebDriverService.find(terminal: terminal) {
|
||||
return service
|
||||
}
|
||||
|
||||
throw WebDriverError.failedToFindWebDriver
|
||||
}
|
||||
}
|
|
@ -144,29 +144,3 @@ func fetchWebContent(at url: URL, timeout: Duration) async throws -> (response:
|
|||
|
||||
return (response: response, body: body)
|
||||
}
|
||||
|
||||
func withRetry<R>(
|
||||
maxAttempts: Int,
|
||||
initialDelay: Duration,
|
||||
retryInterval: Duration,
|
||||
body: () async throws -> R
|
||||
) async throws -> R {
|
||||
try await Task.sleep(for: initialDelay)
|
||||
|
||||
var attempt = 0
|
||||
while true {
|
||||
attempt += 1
|
||||
do {
|
||||
return try await body()
|
||||
} catch {
|
||||
if attempt < maxAttempts {
|
||||
print("attempt \(attempt) failed: \(error), retrying...")
|
||||
|
||||
try await Task.sleep(for: retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
import Foundation
|
||||
import XCTest
|
||||
import CartonHelpers
|
||||
|
||||
@testable import CartonFrontend
|
||||
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import WebDriverClient
|
||||
import XCTest
|
||||
|
||||
final class WebDriverClientTests: XCTestCase {
|
||||
func checkRemoteURL() throws -> URL {
|
||||
guard let value = ProcessInfo.processInfo.environment["WEBDRIVER_REMOTE_URL"] else {
|
||||
throw XCTSkip("Skip WebDriver tests due to no WEBDRIVER_REMOTE_URL env var")
|
||||
}
|
||||
return try XCTUnwrap(URL(string: value), "Invalid URL string: \(value)")
|
||||
}
|
||||
|
||||
func testGoto() async throws {
|
||||
let client = try await WebDriverClient.newSession(
|
||||
endpoint: checkRemoteURL(), httpClient: .shared
|
||||
)
|
||||
try await client.goto(url: "https://example.com")
|
||||
try await client.closeSession()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2022 Carton contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import CartonHelpers
|
||||
import WebDriver
|
||||
import XCTest
|
||||
|
||||
final class WebDriverClientTests: XCTestCase {
|
||||
#if canImport(FoundationNetworking)
|
||||
#else
|
||||
func testGotoURLSession() async throws {
|
||||
let terminal = InteractiveWriter.stdout
|
||||
let service = try await WebDriverServices.find(terminal: terminal)
|
||||
defer {
|
||||
service.dispose()
|
||||
}
|
||||
|
||||
let client = try await service.client(
|
||||
httpClient: URLSessionWebDriverHTTPClient(session: .shared)
|
||||
)
|
||||
try await client.goto(url: "https://example.com")
|
||||
try await client.closeSession()
|
||||
}
|
||||
#endif
|
||||
|
||||
func testGotoCurl() async throws {
|
||||
let terminal = InteractiveWriter.stdout
|
||||
let service = try await WebDriverServices.find(terminal: terminal)
|
||||
defer {
|
||||
service.dispose()
|
||||
}
|
||||
|
||||
let client = try await service.client(
|
||||
httpClient: try XCTUnwrap(CurlWebDriverHTTPClient.find())
|
||||
)
|
||||
try await client.goto(url: "https://example.com")
|
||||
try await client.closeSession()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue