Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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(
Expand All @@ -176,6 +182,6 @@ let package = Package(
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
.testTarget(name: "WebDriverClientTests", dependencies: ["WebDriverClient"]),
.testTarget(name: "WebDriverTests", dependencies: ["WebDriver"]),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CartonKit
import Foundation
import NIOCore
import NIOPosix
import WebDriverClient
import WebDriver

#if canImport(FoundationNetworking)
import FoundationNetworking
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions Sources/CartonHelpers/Retry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
public func withRetry<R>(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

コピペで移動してきた

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
}
}
}
90 changes: 90 additions & 0 deletions Sources/WebDriver/CommandWebDriverService.swift
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここの EventLoopGroup だけ NIOPosixの提供するシングルトンに変えた

他はコピペ

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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このサービスはコマンドで起動するのでdisposeでKILLするためにProcessを持つ

}
}
75 changes: 75 additions & 0 deletions Sources/WebDriver/CurlWebDriverHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここエラーの内容をリッチにした。

path: cliPath,
status: process.terminationStatus,
body: body
)
}
return responseBody ?? Data()
}
}
25 changes: 25 additions & 0 deletions Sources/WebDriver/RemoteWebDriverService.swift
Original file line number Diff line number Diff line change
@@ -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() {}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これはリモートのURLを持つだけなので空

}
37 changes: 37 additions & 0 deletions Sources/WebDriver/URLSessionAsync.swift
Original file line number Diff line number Diff line change
@@ -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
Loading