├── .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:
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 | Label for hidden file input
141 |
142 |
143 |
144 |
145 |
146 |
147 |
150 |
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 | Set cookie slow
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 |
10 | Link
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/spec/support/views/type.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Content
14 |
15 |
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 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/spec/support/views/visit_timeout.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | The below image will take a long time to respond
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/spec/support/views/wanted.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | We should see this.
8 |
9 |
10 |
--------------------------------------------------------------------------------
/spec/support/views/with_ajax_connection_canceled.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ferrum with_js
6 |
7 |
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 | Change me
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | Hidden link
52 |
53 |
54 |
55 |
56 | Browser
57 | Firefox
58 |
59 | Chrome
60 | Safari
61 |
62 | IE
63 |
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 |
--------------------------------------------------------------------------------