diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 6845092..fd733b6 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -8,11 +8,25 @@ on: jobs: verify-chrome: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Crystal + run: curl -sSL https://dist.crystal-lang.org/apt/setup.sh | sudo bash + - name: Install Crystal + run: sudo apt install crystal + - name: Install dependencies + run: shards install + - name: Run tests + run: crystal spec --tag "~chrome" + env: + SELENIUM_BROWSER: chrome + verify-chrome-external-process: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Start Chromedriver - run: chromedriver --port=4444 --url-base=/wd/hub & + run: chromedriver & - name: Setup Crystal run: curl -sSL https://dist.crystal-lang.org/apt/setup.sh | sudo bash - name: Install Crystal @@ -27,8 +41,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Start Geckodriver - run: geckodriver --port=4444 & - name: Setup Crystal run: curl -sSL https://dist.crystal-lang.org/apt/setup.sh | sudo bash - name: Install Crystal diff --git a/README.md b/README.md index b609c8b..dcc4d29 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ require "selenium" ### Creating a Driver ```crystal -driver = Selenium::Driver.for(:chrome) +driver = Selenium::Driver.for(:chrome, base_url: "http://localhost:9515") ``` Available drivers: @@ -34,6 +34,17 @@ Available drivers: - :firefox (using geckodriver) - :remote (general purpose) +### Running with a service + +Rather than running chromedriver yourself, you can give the driver a service which will run the process for you. + +```crystal +driver = Selenium::Driver.for(:chrome, service: Service.chrome(driver_path: "~/.webdrivers/chromedriver")) +``` + +You must call `driver.stop` when you are finished or it will leave the service running. +Consider using [webdrivers.cr](https://github.com/matthewmcgarvey/webdrivers.cr) for automatically installing drivers and managing the driver path for you. + ### Creating a Session ```crystal @@ -46,18 +57,23 @@ Use the appropriate `Capabilities` class for whichever browser you choose. ## Development -To run the tests you must have the appropriate driver running. -The tests use chrome by default. The command to start chromedriver as the tests expect is: +Run `crystal spec` to run the tests. It will run the tests in headless mode. -```bash -chromedriver --port=4444 --url-base=/wd/hub +To run the tests with chrome headlessly: + +```crystal +SELENIUM_BROWSER=chrome crystal spec --tag "~chrome" ``` -Run `crystal spec` to run the tests. It will run the tests in headless mode. +To run the tests with firefox headlessly: -To run the tests with firefox you will need to have the geckodriver running and run `SELENIUM_BROWSER=firefox crystal spec --tag "~firefox"` +```crystal +SELENIUM_BROWSER=firefox crystal spec --tag "~firefox" + +``` -Using the tag `~firefox` is to avoid running the tests that are known to break the specified browser. Please feel free to attemp a fix for them. +The tag skips any specs that are know to break with those browsers. +Running just `crystal spec` will use chrome. ## Contributing diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 1f35e1b..d167836 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,8 +1,30 @@ require "spec" require "http" +require "webdrivers" require "../src/selenium" require "./support/**" +class Global + @@driver : Selenium::Driver? + @@cap : Selenium::Capabilities? + + def self.set(driver, cap) + @@driver = driver + @@cap = cap + end + + def self.create_session + @@driver.not_nil!.create_session(@@cap) + end + + def self.stop + @@driver.not_nil!.stop + end +end + +driver, capabilities = Selenium::TestDriverFactory.build(ENV["SELENIUM_BROWSER"]? || "chrome") +Global.set(driver, capabilities) + server = TestServer.new(3002) Spec.before_each do @@ -11,34 +33,15 @@ end Spec.after_suite do server.close + Global.stop end spawn do server.listen end -def browser - ENV["SELENIUM_BROWSER"]? || "chrome" -end - -def build_session - if browser == "chrome" - driver = Selenium::Driver.for(:chrome) - capabilities = Selenium::Chrome::Capabilities.new - capabilities.args(["no-sandbox", "headless", "disable-gpu"]) - driver.create_session(capabilities) - elsif browser == "firefox" - driver = Selenium::Driver.for(:firefox, base_url: "http://localhost:4444") - capabilities = Selenium::Firefox::Capabilities.new - capabilities.args(["-headless"]) - driver.create_session(capabilities) - else - raise ArgumentError.new("unknown browser for running tests: #{browser}") - end -end - def with_session - session = build_session + session = Global.create_session yield(session) ensure session.delete unless session.nil? diff --git a/spec/support/test_driver_factory.cr b/spec/support/test_driver_factory.cr new file mode 100644 index 0000000..ad06124 --- /dev/null +++ b/spec/support/test_driver_factory.cr @@ -0,0 +1,43 @@ +class Selenium::TestDriverFactory + def self.build(browser) : Tuple(Driver, Capabilities) + case browser + when "chrome" + build_chrome_driver + when "firefox" + build_firefox_driver + when "chrome-no-service" + build_chrome_driver_no_service + else + raise ArgumentError.new("unknown browser for running tests: #{browser}") + end + end + + def self.build_chrome_driver : Tuple(Driver, Capabilities) + capabilities = Chrome::Capabilities.new + capabilities.args(["no-sandbox", "headless", "disable-gpu"]) + driver = Driver.for(:chrome, service: Service.chrome(driver_path: Webdrivers::Chromedriver.install)) + {driver, capabilities} + end + + def self.build_firefox_driver : Tuple(Driver, Capabilities) + capabilities = Firefox::Capabilities.new + capabilities.args(["-headless"]) + driver = Driver.for(:firefox, service: Service.firefox(driver_path: Webdrivers::Geckodriver.install)) + {driver, capabilities} + end + + def self.build_chrome_driver_no_service : Tuple(Driver, Capabilities) + capabilities = Chrome::Capabilities.new + capabilities.args(["no-sandbox", "headless", "disable-gpu"]) + driver = Driver.for(:chrome, base_url: "http://localhost:9515") + {driver, capabilities} + end + + private def self.chrome?(browser) + browser == "chrome" + end + + private def self.firefox?(browser) + browser == "firefox" + end +end diff --git a/src/selenium/chrome/service.cr b/src/selenium/chrome/service.cr new file mode 100644 index 0000000..0f4fb86 --- /dev/null +++ b/src/selenium/chrome/service.cr @@ -0,0 +1,5 @@ +class Selenium::Chrome::Service < Selenium::Service + def default_port : Int32 + 9515 + end +end diff --git a/src/selenium/driver.cr b/src/selenium/driver.cr index 771ed12..e786b27 100644 --- a/src/selenium/driver.cr +++ b/src/selenium/driver.cr @@ -1,17 +1,12 @@ class Selenium::Driver - DEFAULT_CONFIGURATION = { - base_url: "http://localhost:4444/wd/hub", - } - def self.for(browser, **opts) - options = DEFAULT_CONFIGURATION.merge(opts) case browser when :chrome - Chrome::Driver.new(options) + Chrome::Driver.new(**opts) when :firefox, :gecko - Firefox::Driver.new(options) + Firefox::Driver.new(**opts) when :remote - Remote::Driver.new(options) + Remote::Driver.new(**opts) else raise ArgumentError.new "unknown driver: #{browser}" end @@ -19,9 +14,12 @@ class Selenium::Driver getter http_client : HttpClient getter command_handler : CommandHandler + getter service : Service? - def initialize(opts) - @http_client = HttpClient.new(base_url: opts[:base_url]) + def initialize(base_url : String? = nil, @service : Service? = nil) + @service.try &.start + base_url ||= @service.not_nil!.base_url + @http_client = HttpClient.new(base_url: base_url) @command_handler = CommandHandler.new(@http_client) end @@ -37,4 +35,8 @@ class Selenium::Driver Status.from_json(data["value"].to_json) end + + def stop + service.try &.stop + end end diff --git a/src/selenium/firefox/service.cr b/src/selenium/firefox/service.cr new file mode 100644 index 0000000..ed0a573 --- /dev/null +++ b/src/selenium/firefox/service.cr @@ -0,0 +1,9 @@ +class Selenium::Firefox::Service < Selenium::Service + def default_port : Int32 + 4444 + end + + def stop + stop_process(process) + end +end diff --git a/src/selenium/service.cr b/src/selenium/service.cr new file mode 100644 index 0000000..f73e838 --- /dev/null +++ b/src/selenium/service.cr @@ -0,0 +1,102 @@ +abstract class Selenium::Service + CONNECTION_INTERVAL = 1.seconds + CONNECTION_TIMEOUT = 5.seconds + + def self.chrome(**opts) + Chrome::Service.new(**opts) + end + + def self.firefox(**opts) + Firefox::Service.new(**opts) + end + + private property process : Process? + + def initialize(@driver_path : String, @port : Int32 = default_port, @args = [] of String) + end + + def start + start_process + verify_running + end + + def stop + send_shutdown_command + process.try &.wait + ensure + stop_process(process) + end + + def base_url : String + "http://localhost:#{@port}" + end + + abstract def default_port : Int32 + + private def start_process + @process = Process.new( + @driver_path, + ["--port=#{@port}"] | @args, + shell: spawn_in_shell?, + output: {% if flag?(:DEBUG) %} STDOUT {% else %} Process::Redirect::Close {% end %}, + error: {% if flag?(:DEBUG) %} STDERR {% else %} Process::Redirect::Close {% end %} + ) + end + + private def verify_running + result = with_timeout { listening? } + raise "Unable to connect to driver process. Try running in DEBUG mode to find more information." unless result + end + + private def with_timeout + max_time = Time.utc + CONNECTION_TIMEOUT + + until Time.utc > max_time + return true if yield + + sleep CONNECTION_INTERVAL + end + + false + end + + private def listening? + TCPSocket.new("localhost", @port).close + true + rescue + false + end + + private def send_shutdown_command + return if @process.nil? || @process.try &.terminated? + + HTTP::Client.new(host: "localhost", port: @port) do |client| + client.connect_timeout = 10 + client.read_timeout = 10 + headers = HTTP::Headers{ + "Accept" => "application/json", + "Content-Type" => "application/json; charset=UTF-8", + "User-Agent" => "selenium/#{Selenium::VERSION} (crystal #{os})", + } + client.get("/shutdown", headers) + end + end + + private def stop_process(process) + return if process.nil? || process.terminated? + + process.kill + end + + private def spawn_in_shell? + os != "linux" + end + + private def os + {% if flag?(:linux) %} + "linux" + {% else %} + "macos" + {% end %} + end +end