├── .devcontainer ├── Dockerfile ├── base.Dockerfile └── devcontainer.json ├── .document ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── linter.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .yardopts ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console ├── rake └── rspec ├── ferrum.gemspec ├── lib ├── ferrum.rb └── ferrum │ ├── browser.rb │ ├── browser │ ├── binary.rb │ ├── command.rb │ ├── options.rb │ ├── options │ │ ├── base.rb │ │ ├── chrome.rb │ │ └── firefox.rb │ ├── process.rb │ ├── version_info.rb │ └── xvfb.rb │ ├── client.rb │ ├── client │ ├── subscriber.rb │ └── web_socket.rb │ ├── context.rb │ ├── contexts.rb │ ├── cookies.rb │ ├── cookies │ └── cookie.rb │ ├── dialog.rb │ ├── downloads.rb │ ├── errors.rb │ ├── frame.rb │ ├── frame │ ├── dom.rb │ └── runtime.rb │ ├── headers.rb │ ├── keyboard.json │ ├── keyboard.rb │ ├── mouse.rb │ ├── network.rb │ ├── network │ ├── auth_request.rb │ ├── error.rb │ ├── exchange.rb │ ├── intercepted_request.rb │ ├── request.rb │ ├── request_params.rb │ └── response.rb │ ├── node.rb │ ├── page.rb │ ├── page │ ├── animation.rb │ ├── frames.rb │ ├── screencast.rb │ ├── screenshot.rb │ ├── stream.rb │ └── tracing.rb │ ├── proxy.rb │ ├── rgba.rb │ ├── target.rb │ ├── utils │ ├── attempt.rb │ ├── elapsed_time.rb │ ├── event.rb │ ├── platform.rb │ └── thread.rb │ └── version.rb ├── logo.svg └── spec ├── browser ├── binary_spec.rb ├── options │ └── chrome_spec.rb ├── version_info_spec.rb └── xvfb_spec.rb ├── browser_spec.rb ├── context_spec.rb ├── cookies └── cookie_spec.rb ├── cookies_spec.rb ├── dialog_spec.rb ├── downloads_spec.rb ├── frame └── runtime_spec.rb ├── frame_spec.rb ├── headers_spec.rb ├── keyboard_spec.rb ├── mouse_spec.rb ├── network ├── auth_request_spec.rb ├── error_spec.rb ├── exchange_spec.rb ├── intercepted_request_spec.rb ├── request_spec.rb └── response_spec.rb ├── network_spec.rb ├── node_spec.rb ├── page ├── animation_spec.rb ├── screencast_spec.rb ├── screenshot_spec.rb └── tracing_spec.rb ├── page_spec.rb ├── rbga_spec.rb ├── spec_helper.rb ├── support ├── application.rb ├── broken_chrome ├── broken_chrome.bat ├── custom_chrome ├── custom_chrome.bat ├── geolocation.js ├── global_helpers.rb ├── no_chrome ├── no_chrome.bat ├── public │ ├── add_style_tag.css │ ├── attachment.pdf │ ├── jquery-3.7.1.min.js │ ├── jquery-ui-1.13.2.min.js │ └── test.js ├── server.rb └── views │ ├── animation.erb │ ├── attach_file.erb │ ├── attachment.erb │ ├── attributes_properties.erb │ ├── auto_refresh.erb │ ├── basic_auth.erb │ ├── buttons.erb │ ├── click_coordinates.erb │ ├── click_test.erb │ ├── computed_style.erb │ ├── console_log.erb │ ├── custom_html_size.erb │ ├── custom_html_size_100%.erb │ ├── date_fields.erb │ ├── deeply_nested.erb │ ├── double_click_test.erb │ ├── drag.erb │ ├── filter_text_test.erb │ ├── fixed_positioning.erb │ ├── form.erb │ ├── form_iframe.erb │ ├── frame_child.erb │ ├── frame_one.erb │ ├── frame_parent.erb │ ├── frame_two.erb │ ├── frames.erb │ ├── grid.erb │ ├── headers.erb │ ├── headers_with_ajax.erb │ ├── image_map.erb │ ├── index.erb │ ├── input_events.erb │ ├── js_error.erb │ ├── js_redirect.erb │ ├── link_with_ping.erb │ ├── long_page.erb │ ├── nested_frame_test.erb │ ├── orig_with_js.erb │ ├── popup_frames.erb │ ├── popup_headers.erb │ ├── requiring_custom_extension.erb │ ├── scroll.erb │ ├── set.erb │ ├── show_cookies.erb │ ├── simple.erb │ ├── svg_test.erb │ ├── table.erb │ ├── type.erb │ ├── unicode.html │ ├── unwanted.erb │ ├── url_blacklist.erb │ ├── url_whitelist.erb │ ├── visit_timeout.erb │ ├── wanted.erb │ ├── with_ajax_connection_canceled.erb │ ├── with_ajax_connection_refused.erb │ ├── with_ajax_fail.erb │ ├── with_different_resources.erb │ ├── with_js.erb │ ├── with_slow_ajax_connection.erb │ ├── with_user_js.erb │ └── zoom_test.erb ├── tmp └── .keep └── unit ├── browser_spec.rb └── process_spec.rb /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} 4 | 5 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 6 | ARG NODE_VERSION="none" 7 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 8 | 9 | # [Optional] Uncomment this section to install additional OS packages. 10 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 11 | # && apt-get -y install --no-install-recommends chromium 12 | 13 | # OS packages required to run the application 14 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 15 | && apt-get -y install --no-install-recommends \ 16 | libasound2 \ 17 | libatk-bridge2.0-0 \ 18 | libatk1.0-0 \ 19 | libatspi2.0-0 \ 20 | libcairo2 \ 21 | libcups2 \ 22 | libdrm2 \ 23 | libgbm1 \ 24 | libgtk-3-0 \ 25 | libnspr4 \ 26 | libnss3 \ 27 | libpango-1.0-0 \ 28 | libx11-6 \ 29 | libxcb1 \ 30 | libxcomposite1 \ 31 | libxdamage1 \ 32 | libxext6 \ 33 | libxfixes3 \ 34 | libxkbcommon0 \ 35 | libxrandr2 \ 36 | libxshmfence1 \ 37 | xdg-utils 38 | 39 | RUN curl --silent --show-error --location --fail --retry 3 --output /tmp/google-chrome-stable_current_amd64.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ 40 | && (sudo dpkg -i /tmp/google-chrome-stable_current_amd64.deb || sudo apt-get -fy install) \ 41 | && rm -rf /tmp/google-chrome-stable_current_amd64.deb \ 42 | && sudo sed -i 's|HERE/chrome"|HERE/chrome" --disable-setuid-sandbox --no-sandbox|g' \ 43 | "/opt/google/chrome/google-chrome" \ 44 | && google-chrome --version 45 | 46 | RUN mkdir /app 47 | WORKDIR /app 48 | 49 | # Install gems 50 | ARG BUNDLER_VERSION=2.2.11 51 | RUN gem install bundler:${BUNDLER_VERSION} solargraph 52 | 53 | # [Optional] Uncomment this line to install global node packages. 54 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/base.Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM ruby:${VARIANT} 4 | 5 | # Copy library scripts to execute 6 | COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/ 7 | 8 | # [Option] Install zsh 9 | ARG INSTALL_ZSH="true" 10 | # [Option] Upgrade OS packages to their latest versions 11 | ARG UPGRADE_PACKAGES="true" 12 | # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. 13 | ARG USERNAME=vscode 14 | ARG USER_UID=1000 15 | ARG USER_GID=$USER_UID 16 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 17 | # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131 18 | && apt-get purge -y imagemagick imagemagick-6-common \ 19 | # Install common packages, non-root user, rvm, core build tools 20 | && bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ 21 | && bash /tmp/library-scripts/ruby-debian.sh "none" "${USERNAME}" "true" "true" \ 22 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* 23 | 24 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 25 | ARG NODE_VERSION="none" 26 | ENV NVM_DIR=/usr/local/share/nvm 27 | ENV NVM_SYMLINK_CURRENT=true \ 28 | PATH=${NVM_DIR}/current/bin:${PATH} 29 | RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \ 30 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 31 | 32 | # Remove library scripts for final image 33 | RUN rm -rf /tmp/library-scripts 34 | 35 | # [Optional] Uncomment this section to install additional OS packages. 36 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 37 | # && apt-get -y install --no-install-recommends 38 | 39 | # [Optional] Uncomment this line to install additional gems. 40 | # RUN gem install 41 | 42 | # [Optional] Uncomment this line to install global node packages. 43 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.203.0/containers/ruby 3 | { 4 | "name": "Ruby", 5 | "runArgs": ["--init"], 6 | "build": { 7 | "dockerfile": "Dockerfile", 8 | "args": { 9 | // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 10 | // Append -bullseye or -buster to pin to an OS version. 11 | // Use -bullseye variants on local on arm64/Apple Silicon. 12 | "VARIANT": "3-bullseye", 13 | // Options 14 | "NODE_VERSION": "none" 15 | } 16 | }, 17 | 18 | // Set *default* container specific settings.json values on container create. 19 | "settings": {}, 20 | 21 | // Add the IDs of extensions you want installed when the container is created. 22 | "extensions": [ 23 | "rebornix.Ruby" 24 | ], 25 | 26 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 27 | // "forwardPorts": [], 28 | 29 | // Use 'postCreateCommand' to run commands after the container is created. 30 | // "postCreateCommand": "ruby --version", 31 | 32 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 33 | "remoteUser": "vscode", 34 | "features": { 35 | "git": "latest", 36 | "github-cli": "latest" 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | - 3 | CHANGELOG.md 4 | LICENSE 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: rubycdp 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: route 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior, a failing test or a debug log. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Desktop (please complete the following information):** 23 | - OS: [Linux | macOS | Windows] 24 | - Browser [Chrome Version 121.0.6167.160 (Official Build) (arm64)] 25 | - Version [v x.y] 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | linters: 10 | name: Linters 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: [ "2.7", "3.0" ] 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | 26 | - name: Run linters 27 | run: | 28 | bundle exec rubocop --parallel 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | tests: 10 | name: Tests 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3", "3.4"] 15 | runs-on: ubuntu-latest 16 | env: 17 | FERRUM_PROCESS_TIMEOUT: 25 18 | FERRUM_DEFAULT_TIMEOUT: 15 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Setup Chrome 30 | uses: browser-actions/setup-chrome@latest 31 | with: 32 | chrome-version: stable 33 | 34 | - name: Fix GA Chrome Permissions 35 | run: | 36 | sudo chown root:root /opt/hostedtoolcache/setup-chrome/chromium/stable/x64/chrome-sandbox 37 | sudo chmod 4755 /opt/hostedtoolcache/setup-chrome/chromium/stable/x64/chrome-sandbox 38 | 39 | - name: Run tests 40 | run: | 41 | mkdir -p /tmp/ferrum 42 | bundle exec rake 43 | 44 | - name: Archive artifacts 45 | uses: actions/upload-artifact@v4 46 | if: ${{ failure() }} 47 | with: 48 | name: artifacts-ruby-v${{ matrix.ruby }} 49 | path: /tmp/ferrum/ 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | Gemfile.* 3 | doc 4 | pkg 5 | tmp 6 | .idea 7 | .ruby-version 8 | .yardoc 9 | .tool-versions 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format=progress 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | NewCops: enable 4 | SuggestExtensions: false 5 | 6 | Layout/FirstArrayElementIndentation: 7 | EnforcedStyle: consistent 8 | 9 | Naming/MethodParameterName: 10 | MinNameLength: 2 11 | AllowedNames: 12 | - x 13 | - y 14 | 15 | Naming/FileName: 16 | Exclude: 17 | - 'gemfiles/*.gemfile' 18 | 19 | Style/StringLiterals: 20 | EnforcedStyle: double_quotes 21 | 22 | Style/MultilineBlockChain: 23 | Exclude: 24 | - spec/**/* 25 | 26 | Style/Documentation: 27 | Enabled: false 28 | 29 | Metrics/BlockLength: 30 | Exclude: 31 | - spec/**/* 32 | - "*.gemspec" 33 | 34 | Metrics/ParameterLists: 35 | Max: 6 36 | 37 | Metrics/AbcSize: 38 | Max: 66 39 | 40 | Metrics/ClassLength: 41 | Max: 300 42 | Exclude: 43 | - spec/**/* 44 | 45 | Metrics/CyclomaticComplexity: 46 | Max: 14 47 | 48 | Metrics/MethodLength: 49 | Max: 52 50 | 51 | Metrics/ModuleLength: 52 | Exclude: 53 | - spec/**/* 54 | Max: 602 55 | 56 | Metrics/PerceivedComplexity: 57 | Max: 14 58 | 59 | require: 60 | - rubocop-rake 61 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown --title 'Ferrum Documentation' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "byebug", "~> 11.0", platforms: %i[mri mingw x64_mingw] 6 | gem "chunky_png", "~> 1.3" 7 | gem "image_size", "~> 2.0" 8 | gem "kramdown", "~> 2.0", require: false 9 | gem "pdf-reader", "~> 2.12" 10 | gem "puma", ">= 5.6.7" 11 | gem "rake", "~> 13.0" 12 | gem "redcarpet", require: false, platform: :mri 13 | gem "rspec", "~> 3.8" 14 | gem "rspec-wait" 15 | gem "rubocop", "~> 1.22" 16 | gem "rubocop-rake", require: false 17 | gem "sinatra", "~> 3.2" 18 | gem "yard", "~> 0.9", require: false 19 | 20 | gemspec 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Dmitry Vorotilin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "bundler/gem_tasks" 5 | require "rspec/core/rake_task" 6 | 7 | RSpec::Core::RakeTask.new("test") do |t| 8 | t.ruby_opts = "-w" 9 | t.rspec_opts = "--format=documentation" if ENV["CI"] 10 | end 11 | 12 | task default: :test 13 | 14 | begin 15 | require "yard" 16 | YARD::Rake::YardocTask.new 17 | rescue LoadError 18 | # nop 19 | end 20 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("../lib", __dir__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | 7 | require "irb" 8 | require "irb/completion" 9 | require "ferrum" 10 | 11 | def browser(headless: true, **options) 12 | @browser ||= Ferrum::Browser.new(headless: headless, **options) 13 | end 14 | 15 | IRB.start 16 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /ferrum.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/ferrum/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "ferrum" 7 | s.version = Ferrum::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Dmitry Vorotilin"] 10 | s.email = ["d.vorotilin@gmail.com"] 11 | s.homepage = "https://github.com/rubycdp/ferrum" 12 | s.summary = "Ruby headless Chrome driver" 13 | s.description = "Ferrum allows you to control headless Chrome browser" 14 | s.license = "MIT" 15 | s.require_paths = ["lib"] 16 | s.files = Dir["lib/**/*", "LICENSE", "README.md"] 17 | s.metadata = { 18 | "homepage_uri" => "https://ferrum.rubycdp.com/", 19 | "bug_tracker_uri" => "https://github.com/rubycdp/ferrum/issues", 20 | "documentation_uri" => "https://github.com/rubycdp/ferrum/blob/main/README.md", 21 | "changelog_uri" => "https://github.com/rubycdp/ferrum/blob/main/CHANGELOG.md", 22 | "source_code_uri" => "https://github.com/rubycdp/ferrum", 23 | "rubygems_mfa_required" => "true" 24 | } 25 | 26 | s.required_ruby_version = ">= 2.7.0" 27 | 28 | s.add_dependency "addressable", "~> 2.5" 29 | s.add_dependency "base64", "~> 0.2" 30 | s.add_dependency "concurrent-ruby", "~> 1.1" 31 | s.add_dependency "webrick", "~> 1.7" 32 | s.add_dependency "websocket-driver", "~> 0.7" 33 | end 34 | -------------------------------------------------------------------------------- /lib/ferrum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "concurrent-ruby" 4 | require "ferrum/utils/event" 5 | require "ferrum/utils/thread" 6 | require "ferrum/utils/platform" 7 | require "ferrum/utils/elapsed_time" 8 | require "ferrum/utils/attempt" 9 | require "ferrum/errors" 10 | require "ferrum/browser" 11 | require "ferrum/node" 12 | 13 | module Ferrum 14 | end 15 | -------------------------------------------------------------------------------- /lib/ferrum/browser/binary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | module Binary 6 | module_function 7 | 8 | def find(commands) 9 | enum(commands).first 10 | end 11 | 12 | def all(commands) 13 | enum(commands).force 14 | end 15 | 16 | def enum(commands) 17 | paths, exts = prepare_paths 18 | cmds = Array(commands).product(paths, exts) 19 | lazy_find(cmds) 20 | end 21 | 22 | def prepare_paths 23 | exts = (ENV.key?("PATHEXT") ? ENV.fetch("PATHEXT").split(";") : []) << "" 24 | paths = ENV["PATH"].split(File::PATH_SEPARATOR) 25 | raise EmptyPathError if paths.empty? 26 | 27 | [paths, exts] 28 | end 29 | 30 | # rubocop:disable Style/CollectionCompact 31 | def lazy_find(cmds) 32 | cmds.lazy.map do |cmd, path, ext| 33 | absolute_path = File.absolute_path(cmd) 34 | is_absolute_path = absolute_path == cmd 35 | cmd = File.expand_path("#{cmd}#{ext}", path) unless is_absolute_path 36 | 37 | next unless File.executable?(cmd) 38 | next if File.directory?(cmd) 39 | 40 | cmd 41 | end.reject(&:nil?) # .compact isn't defined on Enumerator::Lazy 42 | end 43 | # rubocop:enable Style/CollectionCompact 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/ferrum/browser/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | class Command 6 | NOT_FOUND = "Could not find an executable for the browser. Try to make " \ 7 | "it available on the PATH or set environment variable for " \ 8 | "example BROWSER_PATH=\"/usr/bin/chrome\"" 9 | 10 | # Currently only these browsers support CDP: 11 | # https://github.com/cyrus-and/chrome-remote-interface#implementations 12 | def self.build(options, user_data_dir) 13 | defaults = case options.browser_name 14 | when :firefox 15 | Options::Firefox.options 16 | when :chrome, :opera, :edge, nil 17 | Options::Chrome.options 18 | else 19 | raise NotImplementedError, "not supported browser" 20 | end 21 | 22 | new(defaults, options, user_data_dir) 23 | end 24 | 25 | attr_reader :defaults, :path, :options 26 | 27 | def initialize(defaults, options, user_data_dir) 28 | @flags = {} 29 | @defaults = defaults 30 | @options = options 31 | @user_data_dir = user_data_dir 32 | @path = options.browser_path || ENV.fetch("BROWSER_PATH", nil) || defaults.detect_path 33 | raise BinaryNotFoundError, NOT_FOUND unless @path 34 | 35 | merge_options 36 | end 37 | 38 | def xvfb? 39 | !!options.xvfb 40 | end 41 | 42 | def to_a 43 | [path] + @flags.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" } 44 | end 45 | 46 | def to_s 47 | to_a.join(" \\ \n ") 48 | end 49 | 50 | private 51 | 52 | def merge_options 53 | @flags = defaults.merge_required(@flags, options, @user_data_dir) 54 | @flags = defaults.merge_default(@flags, options) unless options.ignore_default_browser_options 55 | @flags.merge!(options.browser_options) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/ferrum/browser/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | class Options 6 | BROWSER_PORT = "0" 7 | BROWSER_HOST = "127.0.0.1" 8 | WINDOW_SIZE = [1024, 768].freeze 9 | BASE_URL_SCHEMA = %w[http https].freeze 10 | DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i 11 | PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i 12 | DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil? 13 | 14 | attr_reader :window_size, :logger, :ws_max_receive_size, 15 | :js_errors, :base_url, :slowmo, :pending_connection_errors, 16 | :url, :ws_url, :env, :process_timeout, :browser_name, :browser_path, 17 | :save_path, :proxy, :port, :host, :headless, :incognito, :browser_options, 18 | :ignore_default_browser_options, :xvfb, :flatten 19 | attr_accessor :timeout, :default_user_agent 20 | 21 | def initialize(options = nil) 22 | @options = Hash(options&.dup) 23 | 24 | @port = @options.fetch(:port, BROWSER_PORT) 25 | @host = @options.fetch(:host, BROWSER_HOST) 26 | @timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT) 27 | @window_size = @options.fetch(:window_size, WINDOW_SIZE) 28 | @js_errors = @options.fetch(:js_errors, false) 29 | @headless = @options.fetch(:headless, true) 30 | @incognito = @options.fetch(:incognito, true) 31 | @flatten = @options.fetch(:flatten, true) 32 | @pending_connection_errors = @options.fetch(:pending_connection_errors, true) 33 | @process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT) 34 | @slowmo = @options[:slowmo].to_f 35 | 36 | @env = @options[:env] 37 | @xvfb = @options[:xvfb] 38 | @save_path = @options[:save_path] 39 | @browser_name = @options[:browser_name] 40 | @browser_path = @options[:browser_path] 41 | @ws_max_receive_size = @options[:ws_max_receive_size] 42 | @ignore_default_browser_options = @options[:ignore_default_browser_options] 43 | 44 | @proxy = validate_proxy(@options[:proxy]) 45 | @logger = parse_logger(@options[:logger]) 46 | @base_url = parse_base_url(@options[:base_url]) if @options[:base_url] 47 | @url = @options[:url].to_s if @options[:url] 48 | @ws_url = @options[:ws_url].to_s if @options[:ws_url] 49 | 50 | @options = @options.merge(window_size: @window_size).freeze 51 | @browser_options = @options.fetch(:browser_options, {}).freeze 52 | end 53 | 54 | def base_url=(value) 55 | @base_url = parse_base_url(value) 56 | end 57 | 58 | def extensions 59 | @extensions ||= Array(@options[:extensions]).map do |extension| 60 | (extension.is_a?(Hash) && extension[:source]) || File.read(extension) 61 | end 62 | end 63 | 64 | def validate_proxy(options) 65 | return unless options 66 | 67 | raise ArgumentError, "proxy options must be a Hash" unless options.is_a?(Hash) 68 | 69 | if options[:host].nil? && options[:port].nil? 70 | raise ArgumentError, "proxy options must be a Hash with at least :host | :port" 71 | end 72 | 73 | options 74 | end 75 | 76 | def to_h 77 | @options 78 | end 79 | 80 | private 81 | 82 | def parse_logger(logger) 83 | return logger if logger 84 | 85 | !logger && DEBUG_MODE ? $stdout.tap { |s| s.sync = true } : logger 86 | end 87 | 88 | def parse_base_url(value) 89 | parsed = Addressable::URI.parse(value) 90 | unless BASE_URL_SCHEMA.include?(parsed&.normalized_scheme) 91 | raise ArgumentError, "`base_url` should be absolute and include schema: #{BASE_URL_SCHEMA.join(' | ')}" 92 | end 93 | 94 | parsed 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/ferrum/browser/options/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "singleton" 4 | require "open3" 5 | 6 | module Ferrum 7 | class Browser 8 | class Options 9 | class Base 10 | include Singleton 11 | 12 | def self.options 13 | instance 14 | end 15 | 16 | # @return [String, nil] 17 | def self.version 18 | out, = Open3.capture2(instance.detect_path, "--version") 19 | out.strip 20 | rescue Errno::ENOENT 21 | nil 22 | end 23 | 24 | def to_h 25 | self.class::DEFAULT_OPTIONS 26 | end 27 | 28 | def except(*keys) 29 | to_h.reject { |n, _| keys.include?(n) } 30 | end 31 | 32 | def detect_path 33 | Binary.find(self.class::PLATFORM_PATH[Utils::Platform.name]) 34 | end 35 | 36 | def merge_required(flags, options, user_data_dir) 37 | raise NotImplementedError 38 | end 39 | 40 | def merge_default(flags, options) 41 | raise NotImplementedError 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/ferrum/browser/options/chrome.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | class Options 6 | class Chrome < Base 7 | DEFAULT_OPTIONS = { 8 | "headless" => nil, 9 | "hide-scrollbars" => nil, 10 | "mute-audio" => nil, 11 | "enable-automation" => nil, 12 | "disable-web-security" => nil, 13 | "disable-session-crashed-bubble" => nil, 14 | "disable-breakpad" => nil, 15 | "disable-sync" => nil, 16 | "no-first-run" => nil, 17 | "use-mock-keychain" => nil, 18 | "keep-alive-for-test" => nil, 19 | "disable-popup-blocking" => nil, 20 | "disable-extensions" => nil, 21 | "disable-component-extensions-with-background-pages" => nil, 22 | "disable-hang-monitor" => nil, 23 | "disable-features" => "site-per-process,IsolateOrigins,TranslateUI", 24 | "disable-translate" => nil, 25 | "disable-background-networking" => nil, 26 | "enable-features" => "NetworkService,NetworkServiceInProcess", 27 | "disable-background-timer-throttling" => nil, 28 | "disable-backgrounding-occluded-windows" => nil, 29 | "disable-client-side-phishing-detection" => nil, 30 | "disable-default-apps" => nil, 31 | "disable-dev-shm-usage" => nil, 32 | "disable-ipc-flooding-protection" => nil, 33 | "disable-prompt-on-repost" => nil, 34 | "disable-renderer-backgrounding" => nil, 35 | "disable-site-isolation-trials" => nil, 36 | "force-color-profile" => "srgb", 37 | "metrics-recording-only" => nil, 38 | "safebrowsing-disable-auto-update" => nil, 39 | "password-store" => "basic", 40 | "no-startup-window" => nil, 41 | "remote-allow-origins" => "*", 42 | "disable-blink-features" => "AutomationControlled" 43 | # NOTE: --no-sandbox is not needed if you properly set up a user in the container. 44 | # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40 45 | # "no-sandbox" => nil, 46 | }.freeze 47 | 48 | MAC_BIN_PATH = [ 49 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", 50 | "/Applications/Chromium.app/Contents/MacOS/Chromium" 51 | ].freeze 52 | LINUX_BIN_PATH = %w[chrome google-chrome google-chrome-stable google-chrome-beta 53 | chromium chromium-browser google-chrome-unstable].freeze 54 | WINDOWS_BIN_PATH = [ 55 | "C:/Program Files/Google/Chrome/Application/chrome.exe", 56 | "C:/Program Files/Google/Chrome Dev/Application/chrome.exe" 57 | ].freeze 58 | PLATFORM_PATH = { 59 | mac: MAC_BIN_PATH, 60 | windows: WINDOWS_BIN_PATH, 61 | linux: LINUX_BIN_PATH 62 | }.freeze 63 | 64 | def merge_required(flags, options, user_data_dir) 65 | flags = flags.merge("remote-debugging-port" => options.port, 66 | "remote-debugging-address" => options.host, 67 | "window-size" => options.window_size&.join(","), 68 | "user-data-dir" => user_data_dir) 69 | 70 | if options.proxy 71 | flags.merge!("proxy-server" => "#{options.proxy[:host]}:#{options.proxy[:port]}") 72 | flags.merge!("proxy-bypass-list" => options.proxy[:bypass]) if options.proxy[:bypass] 73 | end 74 | 75 | flags 76 | end 77 | 78 | def merge_default(flags, options) 79 | defaults = except("headless", "disable-gpu") if options.headless == false 80 | defaults ||= DEFAULT_OPTIONS 81 | defaults.delete("no-startup-window") if options.incognito == false 82 | # On Windows, the --disable-gpu flag is a temporary workaround for a few bugs. 83 | # See https://bugs.chromium.org/p/chromium/issues/detail?id=737678 for more information. 84 | defaults = defaults.merge("disable-gpu" => nil) if Utils::Platform.windows? 85 | # Use Metal on Apple Silicon 86 | # https://github.com/google/angle#platform-support-via-backing-renderers 87 | defaults = defaults.merge("use-angle" => "metal") if Utils::Platform.mac_arm? 88 | defaults.merge(flags) 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/ferrum/browser/options/firefox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | class Options 6 | class Firefox < Base 7 | DEFAULT_OPTIONS = { 8 | "headless" => nil 9 | }.freeze 10 | 11 | MAC_BIN_PATH = [ 12 | "/Applications/Firefox.app/Contents/MacOS/firefox-bin" 13 | ].freeze 14 | LINUX_BIN_PATH = %w[firefox].freeze 15 | WINDOWS_BIN_PATH = [ 16 | "C:/Program Files/Firefox Developer Edition/firefox.exe", 17 | "C:/Program Files/Mozilla Firefox/firefox.exe" 18 | ].freeze 19 | PLATFORM_PATH = { 20 | mac: MAC_BIN_PATH, 21 | windows: WINDOWS_BIN_PATH, 22 | linux: LINUX_BIN_PATH 23 | }.freeze 24 | 25 | def merge_required(flags, options, user_data_dir) 26 | flags.merge("remote-debugger" => "#{options.host}:#{options.port}", "profile" => user_data_dir) 27 | end 28 | 29 | def merge_default(flags, options) 30 | defaults = except("headless") unless options.headless 31 | 32 | defaults ||= DEFAULT_OPTIONS 33 | defaults.merge(flags) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/ferrum/browser/version_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | # 6 | # The browser's version information returned by [Browser.getVersion]. 7 | # 8 | # [Browser.getVersion]: https://chromedevtools.github.io/devtools-protocol/1-3/Browser/#method-getVersion 9 | # 10 | # @since 0.13 11 | # 12 | class VersionInfo 13 | # 14 | # Initializes the browser's version information. 15 | # 16 | # @param [Hash{String => Object}] properties 17 | # The object properties returned by [Browser.getVersion](https://chromedevtools.github.io/devtools-protocol/1-3/Browser/#method-getVersion). 18 | # 19 | # @api private 20 | # 21 | def initialize(properties) 22 | @properties = properties 23 | end 24 | 25 | # 26 | # The Chrome DevTools protocol version. 27 | # 28 | # @return [String] 29 | # 30 | def protocol_version 31 | @properties["protocolVersion"] 32 | end 33 | 34 | # 35 | # The Chrome version. 36 | # 37 | # @return [String] 38 | # 39 | def product 40 | @properties["product"] 41 | end 42 | 43 | # 44 | # The Chrome revision properties. 45 | # 46 | # @return [String] 47 | # 48 | def revision 49 | @properties["revision"] 50 | end 51 | 52 | # 53 | # The Chrome `User-Agent` string. 54 | # 55 | # @return [String] 56 | # 57 | def user_agent 58 | @properties["userAgent"] 59 | end 60 | 61 | # 62 | # The JavaScript engine version. 63 | # 64 | # @return [String] 65 | # 66 | def js_version 67 | @properties["jsVersion"] 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/ferrum/browser/xvfb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Browser 5 | class Xvfb 6 | NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \ 7 | "it with your package manager" 8 | 9 | def self.start(*args) 10 | new(*args).tap(&:start) 11 | end 12 | 13 | attr_reader :screen_size, :display_id, :pid 14 | 15 | def initialize(options) 16 | @path = Binary.find("Xvfb") 17 | raise BinaryNotFoundError, NOT_FOUND unless @path 18 | 19 | @screen_size = "#{options.window_size.join('x')}x24" 20 | @display_id = (Time.now.to_f * 1000).to_i % 100_000_000 21 | end 22 | 23 | def start 24 | @pid = ::Process.spawn("#{@path} :#{display_id} -screen 0 #{screen_size}") 25 | ::Process.detach(@pid) 26 | end 27 | 28 | def to_env 29 | { "DISPLAY" => ":#{display_id}" } 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ferrum/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "concurrent-ruby" 4 | require "forwardable" 5 | require "ferrum/client/subscriber" 6 | require "ferrum/client/web_socket" 7 | require "ferrum/utils/thread" 8 | 9 | module Ferrum 10 | class SessionClient 11 | attr_reader :client, :session_id 12 | 13 | def self.event_name(event, session_id) 14 | [event, session_id].compact.join("_") 15 | end 16 | 17 | def initialize(client, session_id) 18 | @client = client 19 | @session_id = session_id 20 | end 21 | 22 | def command(method, async: false, **params) 23 | message = build_message(method, params) 24 | @client.send_message(message, async: async) 25 | end 26 | 27 | def on(event, &block) 28 | @client.on(event_name(event), &block) 29 | end 30 | 31 | def off(event, id) 32 | @client.off(event_name(event), id) 33 | end 34 | 35 | def subscribed?(event) 36 | @client.subscribed?(event_name(event)) 37 | end 38 | 39 | def respond_to_missing?(name, include_private) 40 | @client.respond_to?(name, include_private) 41 | end 42 | 43 | def method_missing(name, *args, **opts, &block) 44 | @client.send(name, *args, **opts, &block) 45 | end 46 | 47 | def close 48 | @client.subscriber.clear(session_id: session_id) 49 | end 50 | 51 | private 52 | 53 | def build_message(method, params) 54 | @client.build_message(method, params).merge(sessionId: session_id) 55 | end 56 | 57 | def event_name(event) 58 | self.class.event_name(event, session_id) 59 | end 60 | end 61 | 62 | class Client 63 | extend Forwardable 64 | delegate %i[timeout timeout=] => :options 65 | 66 | attr_reader :ws_url, :options, :subscriber 67 | 68 | def initialize(ws_url, options) 69 | @command_id = 0 70 | @ws_url = ws_url 71 | @options = options 72 | @pendings = Concurrent::Hash.new 73 | @ws = WebSocket.new(ws_url, options.ws_max_receive_size, options.logger) 74 | @subscriber = Subscriber.new 75 | 76 | start 77 | end 78 | 79 | def command(method, async: false, **params) 80 | message = build_message(method, params) 81 | send_message(message, async: async) 82 | end 83 | 84 | def send_message(message, async:) 85 | if async 86 | @ws.send_message(message) 87 | true 88 | else 89 | pending = Concurrent::IVar.new 90 | @pendings[message[:id]] = pending 91 | @ws.send_message(message) 92 | data = pending.value!(timeout) 93 | @pendings.delete(message[:id]) 94 | 95 | raise DeadBrowserError if data.nil? && @ws.messages.closed? 96 | raise TimeoutError unless data 97 | 98 | error, response = data.values_at("error", "result") 99 | raise_browser_error(error) if error 100 | response 101 | end 102 | end 103 | 104 | def on(event, &block) 105 | @subscriber.on(event, &block) 106 | end 107 | 108 | def off(event, id) 109 | @subscriber.off(event, id) 110 | end 111 | 112 | def subscribed?(event) 113 | @subscriber.subscribed?(event) 114 | end 115 | 116 | def session(session_id) 117 | SessionClient.new(self, session_id) 118 | end 119 | 120 | def close 121 | @ws.close 122 | # Give a thread some time to handle a tail of messages 123 | @pendings.clear 124 | @thread.kill unless @thread.join(1) 125 | @subscriber.close 126 | end 127 | 128 | def inspect 129 | "#<#{self.class} " \ 130 | "@command_id=#{@command_id.inspect} " \ 131 | "@pendings=#{@pendings.inspect} " \ 132 | "@ws=#{@ws.inspect}>" 133 | end 134 | 135 | def build_message(method, params) 136 | { method: method, params: params }.merge(id: next_command_id) 137 | end 138 | 139 | private 140 | 141 | def start 142 | @thread = Utils::Thread.spawn do 143 | loop do 144 | message = @ws.messages.pop 145 | break unless message 146 | 147 | if message.key?("method") 148 | @subscriber << message 149 | else 150 | @pendings[message["id"]]&.set(message) 151 | end 152 | end 153 | end 154 | end 155 | 156 | def next_command_id 157 | @command_id += 1 158 | end 159 | 160 | def raise_browser_error(error) 161 | case error["message"] 162 | # Node has disappeared while we were trying to get it 163 | when "No node with given id found", 164 | "Could not find node with given id", 165 | "Inspected target navigated or closed" 166 | raise NodeNotFoundError, error 167 | # Context is lost, page is reloading 168 | when "Cannot find context with specified id" 169 | raise NoExecutionContextError, error 170 | when "No target with given id found" 171 | raise NoSuchPageError 172 | when /Could not compute content quads/ 173 | raise CoordinatesNotFoundError 174 | else 175 | raise BrowserError, error 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/ferrum/client/subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Client 5 | class Subscriber 6 | INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze 7 | 8 | def initialize 9 | @regular = Queue.new 10 | @priority = Queue.new 11 | @on = Concurrent::Hash.new 12 | 13 | start 14 | end 15 | 16 | def <<(message) 17 | if INTERRUPTIONS.include?(message["method"]) 18 | @priority.push(message) 19 | else 20 | @regular.push(message) 21 | end 22 | end 23 | 24 | def on(event, &block) 25 | @on[event] ||= Concurrent::Array.new 26 | @on[event] << block 27 | @on[event].index(block) 28 | end 29 | 30 | def off(event, id) 31 | @on[event].delete_at(id) 32 | true 33 | end 34 | 35 | def subscribed?(event) 36 | @on.key?(event) 37 | end 38 | 39 | def close 40 | @regular_thread&.kill 41 | @priority_thread&.kill 42 | end 43 | 44 | def clear(session_id:) 45 | @on.delete_if { |k, _| k.match?(session_id) } 46 | end 47 | 48 | private 49 | 50 | def start 51 | @regular_thread = Utils::Thread.spawn(abort_on_exception: false) do 52 | loop do 53 | message = @regular.pop 54 | break unless message 55 | 56 | call(message) 57 | end 58 | end 59 | 60 | @priority_thread = Utils::Thread.spawn(abort_on_exception: false) do 61 | loop do 62 | message = @priority.pop 63 | break unless message 64 | 65 | call(message) 66 | end 67 | end 68 | end 69 | 70 | def call(message) 71 | method, session_id, params = message.values_at("method", "sessionId", "params") 72 | event = SessionClient.event_name(method, session_id) 73 | 74 | total = @on[event]&.size.to_i 75 | @on[event]&.each_with_index do |block, index| 76 | # In case of multiple callbacks we provide current index and total 77 | block.call(params, index, total) 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ferrum/client/web_socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "socket" 5 | require "websocket/driver" 6 | 7 | module Ferrum 8 | class Client 9 | class WebSocket 10 | WEBSOCKET_BUG_SLEEP = 0.05 11 | DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze 12 | SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"] 13 | 14 | attr_reader :url, :messages 15 | 16 | def initialize(url, max_receive_size, logger) 17 | @url = url 18 | @logger = logger 19 | uri = URI.parse(@url) 20 | port = uri.port || DEFAULT_PORTS[uri.scheme] 21 | 22 | if port == 443 23 | tcp = TCPSocket.new(uri.host, port) 24 | ssl_context = OpenSSL::SSL::SSLContext.new 25 | @sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context) 26 | @sock.sync_close = true 27 | @sock.connect 28 | else 29 | @sock = TCPSocket.new(uri.host, port) 30 | end 31 | 32 | max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH 33 | @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size) 34 | @messages = Queue.new 35 | 36 | @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS 37 | 38 | @driver.on(:open, &method(:on_open)) 39 | @driver.on(:message, &method(:on_message)) 40 | @driver.on(:close, &method(:on_close)) 41 | 42 | start 43 | 44 | @driver.start 45 | end 46 | 47 | def on_open(_event) 48 | # https://github.com/faye/websocket-driver-ruby/issues/46 49 | sleep(WEBSOCKET_BUG_SLEEP) 50 | end 51 | 52 | def on_message(event) 53 | data = safely_parse_json(event.data) 54 | # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we 55 | # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return 56 | # anything or skip the background notification, but at least we don't crash the thread that crashes the main 57 | # thread and the application. 58 | @messages.push(data) if data 59 | 60 | output = event.data 61 | if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")] 62 | @screenshot_commands.delete(data&.dig("id")) 63 | output.sub!(/{"data":"[^"]*"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64")) 64 | end 65 | 66 | @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n") 67 | end 68 | 69 | def on_close(_event) 70 | @messages.close 71 | @sock.close 72 | @thread.kill 73 | end 74 | 75 | def send_message(data) 76 | @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS 77 | 78 | json = data.to_json 79 | @driver.text(json) 80 | @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}") 81 | end 82 | 83 | def write(data) 84 | @sock.write(data) 85 | rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException 86 | @messages.close 87 | end 88 | 89 | def close 90 | @driver.close 91 | end 92 | 93 | private 94 | 95 | def start 96 | @thread = Utils::Thread.spawn do 97 | loop do 98 | data = @sock.readpartial(512) 99 | break unless data 100 | 101 | @driver.parse(data) 102 | end 103 | rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException 104 | @messages.close 105 | end 106 | end 107 | 108 | def safely_parse_json(data) 109 | JSON.parse(data, max_nesting: false) 110 | rescue JSON::NestingError 111 | # nop 112 | rescue JSON::ParserError 113 | safely_parse_escaped_json(data) 114 | end 115 | 116 | def safely_parse_escaped_json(data) 117 | unescaped_unicode = 118 | data.gsub(/\\u([\da-fA-F]{4})/) { |_| [::Regexp.last_match(1)].pack("H*").unpack("n*").pack("U*") } 119 | escaped_data = unescaped_unicode.encode("UTF-8", "UTF-8", undef: :replace, invalid: :replace, replace: "?") 120 | JSON.parse(escaped_data, max_nesting: false) 121 | rescue JSON::ParserError 122 | # nop 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/ferrum/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ferrum/target" 4 | 5 | module Ferrum 6 | class Context 7 | POSITION = %i[first last].freeze 8 | 9 | attr_reader :id, :targets 10 | 11 | def initialize(client, contexts, id) 12 | @id = id 13 | @client = client 14 | @contexts = contexts 15 | @targets = Concurrent::Map.new 16 | @pendings = Concurrent::Map.new 17 | end 18 | 19 | def default_target 20 | @default_target ||= create_target 21 | end 22 | 23 | def page 24 | default_target.page 25 | end 26 | 27 | def pages 28 | @targets.values.reject(&:iframe?).map(&:page) 29 | end 30 | 31 | # When we call `page` method on target it triggers ruby to connect to given 32 | # page by WebSocket, if there are many opened windows, but we need only one 33 | # it makes more sense to get and connect to the needed one only which 34 | # usually is the last one. 35 | def windows(pos = nil, size = 1) 36 | raise ArgumentError if pos && !POSITION.include?(pos) 37 | 38 | windows = @targets.values.select(&:window?) 39 | windows = windows.send(pos, size) if pos 40 | windows.map(&:page) 41 | end 42 | 43 | def create_page(**options) 44 | target = create_target 45 | target.page = target.build_page(**options) 46 | end 47 | 48 | def create_target 49 | target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"] 50 | 51 | new_pending = Concurrent::IVar.new 52 | pending = @pendings.put_if_absent(target_id, new_pending) || new_pending 53 | resolved = pending.value(@client.timeout) 54 | raise NoSuchTargetError unless resolved 55 | 56 | @pendings.delete(target_id) 57 | @targets[target_id] 58 | end 59 | 60 | def add_target(params:, session_id: nil) 61 | new_target = Target.new(@client, session_id, params) 62 | # `put_if_absent` returns nil if added a new value or existing if there was one already 63 | target = @targets.put_if_absent(new_target.id, new_target) || new_target 64 | @default_target ||= target 65 | 66 | new_pending = Concurrent::IVar.new 67 | pending = @pendings.put_if_absent(target.id, new_pending) || new_pending 68 | pending.try_set(true) 69 | true 70 | end 71 | 72 | def update_target(target_id, params) 73 | @targets[target_id]&.update(params) 74 | end 75 | 76 | def delete_target(target_id) 77 | @targets.delete(target_id) 78 | end 79 | 80 | def attach_target(target_id) 81 | target = @targets[target_id] 82 | raise NoSuchTargetError unless target 83 | 84 | session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true) 85 | target.session_id = session["sessionId"] 86 | true 87 | end 88 | 89 | def find_target 90 | @targets.each_value { |t| return t if yield(t) } 91 | 92 | nil 93 | end 94 | 95 | def close_targets_connection 96 | @targets.each_value do |target| 97 | next unless target.connected? 98 | 99 | target.page.close_connection 100 | end 101 | end 102 | 103 | def dispose 104 | @contexts.dispose(@id) 105 | end 106 | 107 | def target?(target_id) 108 | !!@targets[target_id] 109 | end 110 | 111 | def inspect 112 | %(#<#{self.class} @id=#{@id.inspect} @targets=#{@targets.inspect} @default_target=#{@default_target.inspect}>) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/ferrum/contexts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ferrum/context" 4 | 5 | module Ferrum 6 | class Contexts 7 | ALLOWED_TARGET_TYPES = %w[page iframe].freeze 8 | 9 | include Enumerable 10 | 11 | attr_reader :contexts 12 | 13 | def initialize(client) 14 | @contexts = Concurrent::Map.new 15 | @client = client 16 | subscribe 17 | auto_attach 18 | discover 19 | end 20 | 21 | def default_context 22 | @default_context ||= create 23 | end 24 | 25 | def each(&block) 26 | return enum_for(__method__) unless block_given? 27 | 28 | @contexts.each(&block) 29 | end 30 | 31 | def [](id) 32 | @contexts[id] 33 | end 34 | 35 | def find_by(target_id:) 36 | context = nil 37 | @contexts.each_value { |c| context = c if c.target?(target_id) } 38 | context 39 | end 40 | 41 | def create(**options) 42 | response = @client.command("Target.createBrowserContext", **options) 43 | context_id = response["browserContextId"] 44 | context = Context.new(@client, self, context_id) 45 | @contexts[context_id] = context 46 | context 47 | end 48 | 49 | def dispose(context_id) 50 | context = @contexts[context_id] 51 | context.close_targets_connection 52 | @client.command("Target.disposeBrowserContext", browserContextId: context.id) 53 | @contexts.delete(context_id) 54 | true 55 | end 56 | 57 | def close_connections 58 | @contexts.each_value(&:close_targets_connection) 59 | end 60 | 61 | def reset 62 | @default_context = nil 63 | @contexts.each_key { |id| dispose(id) } 64 | end 65 | 66 | def size 67 | @contexts.size 68 | end 69 | 70 | private 71 | 72 | # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity 73 | def subscribe 74 | @client.on("Target.attachedToTarget") do |params| 75 | info, session_id = params.values_at("targetInfo", "sessionId") 76 | next unless ALLOWED_TARGET_TYPES.include?(info["type"]) 77 | 78 | context_id = info["browserContextId"] 79 | unless @contexts[context_id] 80 | context = Context.new(@client, self, context_id) 81 | @contexts[context_id] = context 82 | @default_context ||= context 83 | end 84 | 85 | @contexts[context_id]&.add_target(session_id: session_id, params: info) 86 | if params["waitingForDebugger"] 87 | @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true) 88 | end 89 | end 90 | 91 | @client.on("Target.targetCreated") do |params| 92 | info = params["targetInfo"] 93 | next unless ALLOWED_TARGET_TYPES.include?(info["type"]) 94 | 95 | context_id = info["browserContextId"] 96 | 97 | if info["type"] == "iframe" && 98 | (target = @contexts[context_id].find_target { |t| t.connected? && t.page.frame_by(id: info["targetId"]) }) 99 | @contexts[context_id]&.add_target(session_id: target.page.client.session_id, params: info) 100 | else 101 | @contexts[context_id]&.add_target(params: info) 102 | end 103 | end 104 | 105 | @client.on("Target.targetInfoChanged") do |params| 106 | info = params["targetInfo"] 107 | next unless ALLOWED_TARGET_TYPES.include?(info["type"]) 108 | 109 | context_id, target_id = info.values_at("browserContextId", "targetId") 110 | @contexts[context_id]&.update_target(target_id, info) 111 | end 112 | 113 | @client.on("Target.targetDestroyed") do |params| 114 | context = find_by(target_id: params["targetId"]) 115 | context&.delete_target(params["targetId"]) 116 | end 117 | 118 | @client.on("Target.targetCrashed") do |params| 119 | context = find_by(target_id: params["targetId"]) 120 | context&.delete_target(params["targetId"]) 121 | end 122 | end 123 | # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity 124 | 125 | def discover 126 | @client.command("Target.setDiscoverTargets", discover: true) 127 | end 128 | 129 | def auto_attach 130 | return unless @client.options.flatten 131 | 132 | @client.command("Target.setAutoAttach", autoAttach: true, waitForDebuggerOnStart: true, flatten: true) 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/ferrum/cookies/cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Cookies 5 | # 6 | # Represents a [cookie value](https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Cookie). 7 | # 8 | class Cookie 9 | # The parsed JSON attributes. 10 | # 11 | # @return [Hash{String => [String, Boolean, nil]}] 12 | attr_reader :attributes 13 | 14 | # 15 | # Initializes the cookie. 16 | # 17 | # @param [Hash{String => String}] attributes 18 | # The parsed JSON attributes. 19 | # 20 | def initialize(attributes) 21 | @attributes = attributes 22 | end 23 | 24 | # 25 | # The cookie's name. 26 | # 27 | # @return [String] 28 | # 29 | def name 30 | attributes["name"] 31 | end 32 | 33 | # 34 | # The cookie's value. 35 | # 36 | # @return [String] 37 | # 38 | def value 39 | attributes["value"] 40 | end 41 | 42 | # 43 | # The cookie's domain. 44 | # 45 | # @return [String] 46 | # 47 | def domain 48 | attributes["domain"] 49 | end 50 | 51 | # 52 | # The cookie's path. 53 | # 54 | # @return [String] 55 | # 56 | def path 57 | attributes["path"] 58 | end 59 | 60 | # 61 | # The `sameSite` configuration. 62 | # 63 | # @return ["Strict", "Lax", "None", nil] 64 | # 65 | def samesite 66 | attributes["sameSite"] 67 | end 68 | alias same_site samesite 69 | 70 | # 71 | # The cookie's size. 72 | # 73 | # @return [Integer] 74 | # 75 | def size 76 | attributes["size"] 77 | end 78 | 79 | # 80 | # Specifies whether the cookie is secure or not. 81 | # 82 | # @return [Boolean] 83 | # 84 | def secure? 85 | attributes["secure"] 86 | end 87 | 88 | # 89 | # Specifies whether the cookie is HTTP-only or not. 90 | # 91 | # @return [Boolean] 92 | # 93 | def httponly? 94 | attributes["httpOnly"] 95 | end 96 | alias http_only? httponly? 97 | 98 | # 99 | # Specifies whether the cookie is a session cookie or not. 100 | # 101 | # @return [Boolean] 102 | # 103 | def session? 104 | attributes["session"] 105 | end 106 | 107 | # 108 | # Specifies when the cookie will expire. 109 | # 110 | # @return [Time, nil] 111 | # 112 | def expires 113 | Time.at(attributes["expires"]) if attributes["expires"].positive? 114 | end 115 | 116 | # 117 | # The priority of the cookie. 118 | # 119 | # @return [String] 120 | # 121 | def priority 122 | @attributes["priority"] 123 | end 124 | 125 | # 126 | # @return [Boolean] 127 | # 128 | def sameparty? 129 | @attributes["sameParty"] 130 | end 131 | 132 | alias same_party? sameparty? 133 | 134 | # 135 | # @return [String] 136 | # 137 | def source_scheme 138 | @attributes["sourceScheme"] 139 | end 140 | 141 | # 142 | # @return [Integer] 143 | # 144 | def source_port 145 | @attributes["sourcePort"] 146 | end 147 | 148 | # 149 | # Compares different cookie objects. 150 | # 151 | # @return [Boolean] 152 | # 153 | def ==(other) 154 | other.class == self.class && other.attributes == attributes 155 | end 156 | 157 | # 158 | # Converts the cookie back into a raw cookie String. 159 | # 160 | # @return [String] 161 | # The raw cookie string. 162 | # 163 | def to_s 164 | string = String.new("#{@attributes['name']}=#{@attributes['value']}") 165 | 166 | @attributes.each do |key, value| 167 | case key 168 | when "name", "value" # no-op 169 | when "domain" then string << "; Domain=#{value}" 170 | when "path" then string << "; Path=#{value}" 171 | when "expires" then string << "; Expires=#{Time.at(value).httpdate}" 172 | when "httpOnly" then string << "; httpOnly" if value 173 | when "secure" then string << "; Secure" if value 174 | end 175 | end 176 | 177 | string 178 | end 179 | 180 | alias to_h attributes 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/ferrum/dialog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Dialog 5 | attr_reader :message, :default_prompt 6 | 7 | def initialize(page, params) 8 | @page = page 9 | @message = params["message"] 10 | @default_prompt = params["defaultPrompt"] 11 | end 12 | 13 | # 14 | # Accept dialog with given text or default prompt if applicable 15 | # 16 | # @param [String, nil] prompt_text 17 | # 18 | # @example 19 | # browser = Ferrum::Browser.new 20 | # browser.on(:dialog) do |dialog| 21 | # if dialog.match?(/bla-bla/) 22 | # dialog.accept 23 | # else 24 | # dialog.dismiss 25 | # end 26 | # end 27 | # browser.go_to("https://google.com") 28 | # 29 | def accept(prompt_text = nil) 30 | options = { accept: true } 31 | response = prompt_text || default_prompt 32 | options.merge!(promptText: response) if response 33 | @page.command("Page.handleJavaScriptDialog", slowmoable: true, **options) 34 | end 35 | 36 | # 37 | # Dismiss dialog. 38 | # 39 | # @example 40 | # browser = Ferrum::Browser.new 41 | # browser.on(:dialog) do |dialog| 42 | # if dialog.match?(/bla-bla/) 43 | # dialog.accept 44 | # else 45 | # dialog.dismiss 46 | # end 47 | # end 48 | # browser.go_to("https://google.com") 49 | # 50 | def dismiss 51 | @page.command("Page.handleJavaScriptDialog", slowmoable: true, accept: false) 52 | end 53 | 54 | def match?(regexp) 55 | !!message.match(regexp) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/ferrum/downloads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Downloads 5 | VALID_BEHAVIOR = %i[deny allow allowAndName default].freeze 6 | 7 | def initialize(page) 8 | @page = page 9 | @event = Utils::Event.new.tap(&:set) 10 | @files = {} 11 | end 12 | 13 | def files 14 | @files.values 15 | end 16 | 17 | def wait(timeout = 5) 18 | @event.reset 19 | yield if block_given? 20 | @event.wait(timeout) 21 | @event.set 22 | end 23 | 24 | def set_behavior(save_path:, behavior: :allow) 25 | raise ArgumentError unless VALID_BEHAVIOR.include?(behavior.to_sym) 26 | raise Error, "supply absolute path for `:save_path` option" unless Pathname.new(save_path.to_s).absolute? 27 | 28 | @page.command("Browser.setDownloadBehavior", 29 | browserContextId: @page.context_id, 30 | downloadPath: save_path, 31 | behavior: behavior, 32 | eventsEnabled: true) 33 | end 34 | 35 | def subscribe 36 | subscribe_download_will_begin 37 | subscribe_download_progress 38 | end 39 | 40 | def subscribe_download_will_begin 41 | @page.on("Browser.downloadWillBegin") do |params| 42 | @event.reset 43 | @files[params["guid"]] = params 44 | end 45 | end 46 | 47 | def subscribe_download_progress 48 | @page.on("Browser.downloadProgress") do |params| 49 | @files[params["guid"]].merge!(params) 50 | 51 | case params["state"] 52 | when "completed", "canceled" 53 | @event.set 54 | else 55 | @event.reset 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/ferrum/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ferrum 4 | class Error < StandardError; end 5 | class NoSuchPageError < Error; end 6 | class NoSuchTargetError < Error; end 7 | class NotImplementedError < Error; end 8 | class BinaryNotFoundError < Error; end 9 | class EmptyPathError < Error; end 10 | class ServerError < Error; end 11 | 12 | class StatusError < Error 13 | def initialize(url, message = nil) 14 | super(message || "Request to #{url} failed to reach server, check DNS and server status") 15 | end 16 | end 17 | 18 | class PendingConnectionsError < StatusError 19 | attr_reader :pendings 20 | 21 | def initialize(url, pendings = []) 22 | @pendings = pendings 23 | 24 | message = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}" 25 | 26 | super(url, message) 27 | end 28 | end 29 | 30 | class TimeoutError < Error 31 | def message 32 | "Timed out waiting for response. It's possible that this happened " \ 33 | "because something took a very long time (for example a page load " \ 34 | "was slow). If so, setting the :timeout option to a higher value might " \ 35 | "help." 36 | end 37 | end 38 | 39 | class ScriptTimeoutError < Error 40 | def message 41 | "Timed out waiting for evaluated script to return a value" 42 | end 43 | end 44 | 45 | class ProcessTimeoutError < Error 46 | attr_reader :output 47 | 48 | def initialize(timeout, output) 49 | @output = output 50 | super("Browser did not produce websocket url within #{timeout} seconds, try to increase `:process_timeout`. See https://github.com/rubycdp/ferrum#customization") 51 | end 52 | end 53 | 54 | class DeadBrowserError < Error 55 | def initialize(message = "Browser is dead or given window is closed") 56 | super 57 | end 58 | end 59 | 60 | class NodeMovingError < Error 61 | def initialize(node, prev, current) 62 | @node = node 63 | @prev = prev 64 | @current = current 65 | super(message) 66 | end 67 | 68 | def message 69 | "#{@node.inspect} that you're trying to click is moving, hence " \ 70 | "we cannot. Previously it was at #{@prev.inspect} but now at " \ 71 | "#{@current.inspect}." 72 | end 73 | end 74 | 75 | class CoordinatesNotFoundError < Error 76 | def initialize(message = "Could not compute content quads") 77 | super 78 | end 79 | end 80 | 81 | class InvalidScreenshotFormatError < Error 82 | def initialize(format) 83 | valid_formats = Page::Screenshot::SUPPORTED_SCREENSHOT_FORMAT.join(" | ") 84 | super("Invalid value #{format} for option `:format` (#{valid_formats})") 85 | end 86 | end 87 | 88 | class BrowserError < Error 89 | attr_reader :response 90 | 91 | def initialize(response) 92 | @response = response 93 | super(response["message"]) 94 | end 95 | 96 | def code 97 | response["code"] 98 | end 99 | 100 | def data 101 | response["data"] 102 | end 103 | end 104 | 105 | class NodeNotFoundError < BrowserError; end 106 | 107 | class NoExecutionContextError < BrowserError 108 | def initialize(response = nil) 109 | super(response || { "message" => "There's no context available" }) 110 | end 111 | end 112 | 113 | class JavaScriptError < BrowserError 114 | attr_reader :class_name, :message, :stack_trace 115 | 116 | def initialize(response, stack_trace = nil) 117 | @class_name, @message = response.values_at("className", "description") 118 | @stack_trace = stack_trace 119 | super(response.merge("message" => @message)) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/ferrum/frame.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ferrum/frame/dom" 4 | require "ferrum/frame/runtime" 5 | 6 | module Ferrum 7 | class Frame 8 | include DOM 9 | include Runtime 10 | 11 | STATE_VALUES = %i[ 12 | started_loading 13 | navigated 14 | stopped_loading 15 | ].freeze 16 | 17 | # The Frame's unique id. 18 | # 19 | # @return [String] 20 | attr_accessor :id 21 | 22 | # If frame was given a name it should be here. 23 | # 24 | # @return [String, nil] 25 | attr_accessor :name 26 | 27 | # The page the frame belongs to. 28 | # 29 | # @return [Page] 30 | attr_reader :page 31 | 32 | # Parent frame id if this one is nested in another one. 33 | # 34 | # @return [String, nil] 35 | attr_reader :parent_id 36 | 37 | # One of the states frame's in. 38 | # 39 | # @return [:started_loading, :navigated, :stopped_loading, nil] 40 | attr_reader :state 41 | 42 | def initialize(id, page, parent_id = nil) 43 | @id = id 44 | @page = page 45 | @parent_id = parent_id 46 | @execution_id = Concurrent::MVar.new 47 | end 48 | 49 | def state=(value) 50 | raise ArgumentError unless STATE_VALUES.include?(value) 51 | 52 | @state = value 53 | end 54 | 55 | # 56 | # Returns current frame's `location.href`. 57 | # 58 | # @return [String] 59 | # 60 | # @example 61 | # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe") 62 | # frame = browser.frames[1] 63 | # frame.url # => https://interactive-examples.mdn.mozilla.net/pages/tabbed/iframe.html 64 | # 65 | def url 66 | evaluate("document.location.href") 67 | end 68 | 69 | # 70 | # Returns current frame's title. 71 | # 72 | # @return [String] 73 | # 74 | # @example 75 | # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe") 76 | # frame = browser.frames[1] 77 | # frame.title # => HTML Demo: 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /spec/support/views/frame_one.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This is the title of frame one 5 | 6 | 7 | 8 |
This is the text of divInFrameOne
9 |
Some other text
10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/support/views/frame_parent.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This is the parent frame title 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /spec/support/views/frame_two.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This is the title of frame two 5 | 6 | 7 | 8 |
This is the text of divInFrameTwo
9 | 10 | 11 | -------------------------------------------------------------------------------- /spec/support/views/frames.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /spec/support/views/grid.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 35 | 36 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /spec/support/views/headers.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <% for header in request.env.select {|k,v| k.match("^HTTP.*")} %> 8 | <%=header[0].split("_",2)[1]%>: <%=header[1]%> 9 | <% end %> 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/support/views/headers_with_ajax.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 17 | 18 | 19 |
20 | <% for header in request.env.select {|k,v| k.match("^HTTP.*")} %> 21 | <%=header[0].split("_",2)[1]%>: <%=header[1]%> 22 | <% end %> 23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /spec/support/views/image_map.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /spec/support/views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | JS redirect 8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/support/views/input_events.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /spec/support/views/js_error.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 |

hello

12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/support/views/js_redirect.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/support/views/link_with_ping.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

8 | Link with ping 9 |

10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/support/views/nested_frame_test.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/support/views/orig_with_js.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | with_js 6 | 7 | 8 | 9 | 10 | 11 | 12 |

FooBar

13 | 14 |

This is text

15 | This link is non-HTML5 draggable 16 |
17 |

This is a draggable element.

18 |
19 |
20 |

It should be dropped here.

21 |
22 |
23 |

It should be dropped here.

24 |
25 |
26 |

It should be dropped here.

27 |
28 |
29 |

This is an HTML5 draggable element.

30 |
31 |

This is an HTML5 draggable link

32 |
33 |

It should be dropped here.

34 |
35 | 36 |

Click me

37 |

Slowly

38 | 39 |

40 | 44 |

45 | 46 |

47 | 48 |

49 | 50 |

51 | 52 |

53 | 54 |

55 |

Editable content
56 |
57 |
58 | Some content 59 |
Content
60 |
61 |

62 | 63 |

64 | 65 |

66 | 67 |

68 | 69 |

70 | 71 |

72 | Reload! 73 | this won't change 74 |

waiting to be reloaded
75 |

76 | 77 |

78 | Fetch new list! 79 |

    80 |
  • Item 1
  • 81 |
  • Item 2
  • 82 |
83 |

84 | 85 |

86 | Change title 87 |

88 | 89 |

90 | Change size 91 |

92 | 93 |

Click me

94 | 95 |

96 | Open alert 97 | Alert page change 98 |

99 | 100 |

101 | Open delayed alert 102 | Open slow alert 103 |

104 | 105 |

106 | Open confirm 107 |

108 | 109 |

110 | Open check twice 111 |

112 | 113 |

114 | Open prompt 115 |

116 | 117 |

118 | Open defaulted prompt 119 |

120 | 121 |

122 | 123 |

124 |

125 | 126 |

127 |

128 | Change page 129 | Non-escaped query options 130 | Escaped query options 131 |

132 | 133 |

134 | 135 |

136 |

137 | 138 |

139 | 140 | 141 |

142 | 143 |

144 | 145 |

146 | 147 |
148 |

This is a draggable element.

149 |
150 |
151 |

This is an HTML5 draggable element.

152 |
153 | 154 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /spec/support/views/popup_frames.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pop up 8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/support/views/popup_headers.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pop up 8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/support/views/requiring_custom_extension.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Location:

8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /spec/support/views/scroll.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | scroll 5 | 6 | 7 | 8 |
9 |
10 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin lacus odio, dapibus id bibendum in, rhoncus sed dolor. In quis nulla at diam euismod suscipit vitae vitae sapien. Nam viverra hendrerit augue a accumsan. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Fusce fermentum tortor at neque malesuada sodales. Nunc quis augue a quam venenatis pharetra sit amet et risus. Nulla pharetra enim a leo varius scelerisque aliquam urna vestibulum. Sed felis eros, iaculis convallis fermentum ac, condimentum ac lacus. Sed turpis magna, tristique eu faucibus non, faucibus vitae elit. Morbi venenatis adipiscing aliquam.

11 |
12 |

13 | Link outside viewport 14 |

15 |
16 |
17 | 18 |
Below the fold
19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /spec/support/views/set.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
Content
10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/support/views/show_cookies.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= request.cookies["stealth"] %> 10 | 11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/support/views/simple.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | 8 | 11 | Link 12 |

Foo
Bar

13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/support/views/svg_test.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | svg foo 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /spec/support/views/table.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 |
10 | Link 11 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /spec/support/views/type.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
Content
14 | 15 |
16 | 17 |
18 |
19 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /spec/support/views/unicode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

8 | 9 |

10 |

11 | 12 |

13 |

14 | 15 |

16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /spec/support/views/unwanted.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We shouldn't see this. 8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/support/views/url_blacklist.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | We are loading some unwanted action here. 10 | 11 | 12 | 13 |

Disappearing header

14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /spec/support/views/url_whitelist.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We are loading some wanted action here. 8 | 9 | 10 | 11 | 8 | 9 | 10 |

Here

11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /spec/support/views/with_ajax_connection_refused.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ferrum with_js 6 | 7 | 8 | 9 | 10 |

Here

11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/support/views/with_ajax_fail.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ferrum with_js 6 | 7 | 8 | 9 | 10 |

Here

11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/support/views/with_different_resources.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ferrum with_different_resources 6 | 7 | 8 | 9 | 10 | Do redirect 11 | Go to 200 12 | Go to 201 13 | Go to 402 14 | Go to 500 15 | 16 | 17 | -------------------------------------------------------------------------------- /spec/support/views/with_js.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ferrum with_js 6 | 7 | 8 | 9 | 10 | 28 | 29 | 30 |

Remove me

31 |

Remove

32 |

33 |

34 |

35 |

36 | 37 | 38 |

39 |

40 |

41 |

42 |

43 |

44 |

45 |

46 |

47 |

48 |

49 |

50 | 51 | Hidden link 52 | 53 | 54 | 55 | 64 |

65 | Open for match 66 |

67 |

68 | Open check twice 69 |

70 | 71 | 72 | -------------------------------------------------------------------------------- /spec/support/views/with_slow_ajax_connection.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Slow AJAX

10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/support/views/with_user_js.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ferrum with_user_js 6 | 7 | 8 | 9 |
10 |
languages
11 |
12 |
13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/support/views/zoom_test.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubycdp/ferrum/180f29297f2be7b770b32d8a2f73343e9530e67c/spec/tmp/.keep -------------------------------------------------------------------------------- /spec/unit/browser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringio" 4 | require "logger" 5 | 6 | describe Ferrum::Browser do 7 | it "logs requests and responses with native Logger" do 8 | custom_logger = Class.new do 9 | def initialize(logger) 10 | @logger = logger 11 | end 12 | 13 | def puts(*args) 14 | @logger << args 15 | end 16 | end 17 | file_path = "test.log" 18 | logger = custom_logger.new(Logger.new(file_path)) 19 | browser = Ferrum::Browser.new(logger: logger) 20 | browser.body 21 | file_log = File.read(file_path) 22 | expect(file_log).to include("return document.documentElement?.outerHTML") 23 | expect(file_log).to include("") 24 | ensure 25 | FileUtils.rm_f(file_path) 26 | browser.quit 27 | end 28 | 29 | it "logs requests and responses" do 30 | logger = StringIO.new 31 | browser = Ferrum::Browser.new(logger: logger) 32 | 33 | browser.body 34 | 35 | expect(logger.string).to include("return document.documentElement?.outerHTML") 36 | expect(logger.string).to include("") 37 | ensure 38 | browser.quit 39 | end 40 | 41 | it "shows command line options passed" do 42 | browser = Ferrum::Browser.new(browser_options: { "blink-settings" => "imagesEnabled=false" }) 43 | 44 | arguments = browser.command("Browser.getBrowserCommandLine")["arguments"] 45 | 46 | expect(arguments).to include("--blink-settings=imagesEnabled=false") 47 | ensure 48 | browser.quit 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/unit/process_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Ferrum::Browser::Process do 4 | subject { Ferrum::Browser.new(port: 6000, host: "127.0.0.1") } 5 | 6 | unless Ferrum::Utils::Platform.windows? 7 | it "forcibly kills the child if it does not respond to SIGTERM" do 8 | allow(Process).to receive(:spawn).and_return(5678) 9 | allow(Process).to receive(:wait).and_return(nil) 10 | allow(Ferrum::Client).to receive(:new).and_return(double.as_null_object) 11 | 12 | allow_any_instance_of(Ferrum::Browser::Process).to receive(:parse_ws_url) 13 | allow_any_instance_of(Ferrum::Browser::Process).to receive(:parse_json_version) 14 | 15 | subject.send(:start) 16 | 17 | expect(Process).to receive(:kill).with("USR1", 5678).ordered 18 | expect(Process).to receive(:kill).with("KILL", 5678).ordered 19 | 20 | subject.quit 21 | end 22 | end 23 | 24 | context "env variables" do 25 | subject { Ferrum::Browser.new(env: { "LD_PRELOAD" => "some.so" }) } 26 | 27 | it "passes through env" do 28 | allow(Process).to receive(:wait).and_return(nil) 29 | allow(Ferrum::Client).to receive(:new).and_return(double.as_null_object) 30 | 31 | allow(Process).to receive(:spawn).with({ "LD_PRELOAD" => "some.so" }, any_args).and_return(123_456_789) 32 | 33 | allow_any_instance_of(Ferrum::Browser::Process).to receive(:parse_ws_url) 34 | allow_any_instance_of(Ferrum::Browser::Process).to receive(:parse_json_version) 35 | 36 | subject.send(:start) 37 | subject.quit 38 | end 39 | end 40 | end 41 | --------------------------------------------------------------------------------