Combine the WebDriver setup process into WebDriverService and make it easier to reuse (#474)

This commit is contained in:
omochimetaru 2024-05-28 10:12:51 +09:00 committed by GitHub
parent 6cc68d3ba8
commit f7d0b5640f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 503 additions and 237 deletions

View File

@ -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"]),
]
)

View File

@ -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 {

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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() {}
}

View File

@ -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

View File

@ -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

View File

@ -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()
}
}

View File

@ -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)"
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -17,6 +17,7 @@
import Foundation
import XCTest
import CartonHelpers
@testable import CartonFrontend

View File

@ -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()
}
}

View File

@ -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()
}
}