├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── archive ├── console └── setup ├── chromate.gemspec ├── docker_root ├── Gemfile ├── Gemfile.lock ├── TestInDocker.gif └── app.rb ├── dockerfiles ├── Dockerfile ├── README.md └── docker-entrypoint.sh ├── docs ├── BOT_BROWSER.md ├── README.md ├── browser.md ├── client.md ├── element.md └── elements │ ├── checkbox.md │ └── radio.md ├── lib ├── bot_browser.rb ├── bot_browser │ ├── downloader.rb │ └── installer.rb ├── chromate.rb └── chromate │ ├── actions │ ├── dom.rb │ ├── navigate.rb │ ├── screenshot.rb │ └── stealth.rb │ ├── binary.rb │ ├── browser.rb │ ├── c_logger.rb │ ├── client.rb │ ├── configuration.rb │ ├── element.rb │ ├── elements │ ├── checkbox.rb │ ├── option.rb │ ├── radio.rb │ ├── select.rb │ └── tags.rb │ ├── exceptions.rb │ ├── files │ ├── agents.json │ └── stealth.js │ ├── hardwares.rb │ ├── hardwares │ ├── keyboard_controller.rb │ ├── keyboards │ │ └── virtual_controller.rb │ ├── mouse_controller.rb │ └── mouses │ │ ├── linux_controller.rb │ │ ├── mac_os_controller.rb │ │ ├── virtual_controller.rb │ │ └── x11.rb │ ├── helpers.rb │ ├── user_agent.rb │ └── version.rb ├── logo.png ├── results ├── bot.png ├── brotector.png ├── cloudflare.png ├── headers.png └── pixelscan.png ├── sig └── chromate.rbs └── spec ├── apps ├── complex_login │ └── index.html ├── dom_actions │ └── index.html ├── drag_and_drop │ └── index.html ├── fill_form │ ├── clickViewer.css │ ├── clickViewer.js │ └── index.html ├── shadow_checkbox │ └── index.html ├── where_clicked │ ├── clickViewer.css │ ├── clickViewer.js │ └── index.html └── where_moved │ └── index.html ├── browser ├── dom_actions_spec.rb ├── form_spec.rb ├── mouse_spec.rb └── shadow_dom_spec.rb ├── chromate ├── binary_spec.rb ├── browser_spec.rb ├── client_spec.rb ├── configuration_spec.rb ├── element_spec.rb └── elements │ ├── checkbox_spec.rb │ ├── option_spec.rb │ ├── radio_spec.rb │ ├── select_spec.rb │ └── tags_spec.rb ├── chromate_spec.rb ├── spec_helper.rb ├── support ├── cleanup_helper.rb ├── cleanup_spec.rb ├── client_helper.rb ├── modes.rb └── server.rb └── video-records └── .keep /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.2.2' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: browser-actions/setup-chrome@v1 23 | id: setup-chrome 24 | 25 | - name: Set env vars 26 | run: | 27 | echo "CHROME_BIN=${{ steps.setup-chrome.outputs.chrome-path }}" >> $GITHUB_ENV 28 | 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby }} 33 | bundler-cache: true 34 | 35 | - name: Run rubocop 36 | run: bundle exec rubocop 37 | 38 | - name: Run tests 39 | run: bundle exec rspec 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Gem Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | ruby: 13 | - '3.2.2' 14 | if: github.event_name == 'release' 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | 25 | - name: Install dependencies 26 | run: bundle install --jobs 4 --retry 3 27 | 28 | - name: Build Gem 29 | run: bundle exec rake build 30 | env: 31 | DEPLOY_MODE: github 32 | 33 | - uses: fac/ruby-gem-setup-credentials-action@v2 34 | with: 35 | token: ${{ secrets.CI_TOKEN }} 36 | 37 | - uses: fac/ruby-gem-push-action@v2 38 | with: 39 | key: github 40 | pre-release: true 41 | 42 | - name: Publish to RubyGems 43 | run: | 44 | mkdir -p $HOME/.gem 45 | touch $HOME/.gem/credentials 46 | chmod 0600 $HOME/.gem/credentials 47 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 48 | gem build *.gemspec 49 | gem push *.gem 50 | env: 51 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" 52 | DEPLOY_MODE: rubygems -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | *.log 14 | .DS_Store 15 | chromate.tar.gz 16 | spec/apps/**/*.png 17 | spec/video-records/*.mp4 18 | 19 | # docker testing 20 | docker_root/*.log 21 | docker_root/*.mp4 22 | docker_root/*.png -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.0 3 | NewCops: enable 4 | Exclude: 5 | - 'Rakefile' 6 | - 'bin/**/*' 7 | - 'vendor/**/*' 8 | - 'spec/support/**/*' 9 | - 'docker_root/**/*' 10 | 11 | Style/StringLiterals: 12 | EnforcedStyle: single_quotes 13 | 14 | Style/StringLiteralsInInterpolation: 15 | EnforcedStyle: double_quotes 16 | 17 | Style/Documentation: 18 | Enabled: false 19 | 20 | Layout/LineLength: 21 | Max: 160 22 | 23 | Metrics/MethodLength: 24 | Max: 25 25 | 26 | Metrics/AbcSize: 27 | Max: 30 28 | 29 | Metrics/CyclomaticComplexity: 30 | Max: 15 31 | 32 | Metrics/PerceivedComplexity: 33 | Max: 15 34 | 35 | Metrics/ClassLength: 36 | Max: 200 37 | 38 | Metrics/BlockLength: 39 | Max: 50 40 | Exclude: 41 | - 'spec/**/*' -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Unreleased] 2 | 3 | ## 🚀 Features 4 | - **Add specialized elements**: Added support for Select, Radio, and Checkbox elements with specific behaviors 5 | 6 | ## 🛠 Core Enhancements 7 | - **Optimize element initialization**: Reduced redundant element searches by reusing element information 8 | - **Add native option select**: Added support for native select option handling 9 | - **Improve element attributes**: Exposed element attributes (node_id, object_id, root_id) for better control 10 | - **Add BotBrowser binary usage**: Integrated BotBrowser binary for improved automation capabilities 11 | 12 | # Changelog v0.0.2.pre 13 | 14 | ## 🚀 Features 15 | - **Add keyboard actions**: Implemented keyboard interactions, including typing and key pressing 16 | - **Add drag and drop action**: Implemented drag and drop between elements 17 | - **Add b64 screenshot option**: Added support for base64 encoded screenshots 18 | 19 | ## 🛠 Core Enhancements 20 | - **Improve mouse movements**: Keep mouse position between interaction during browser session 21 | - **Improve mouse movements**: Improving Bézier curve usage for more human like behavior 22 | - **Add log level**: Added configurable logging levels 23 | - **Keep hardwares during session**: Maintain hardware state throughout the browser session 24 | - **Add magick find**: Enhanced element finding capabilities 25 | - **Refactor callFunctionOn usage**: Improved JavaScript function calling mechanism 26 | - **Add started? method**: New method to check browser startup status 27 | - **Wait for debug url**: Enhanced browser startup synchronization 28 | 29 | # Changelog v0.0.1.pre 30 | 31 | ## 🚀 Features 32 | - **Add virtual mouse on macOS**: Introduced a virtual mouse controller for macOS. (commit `9cb095b`) 33 | - **Abstract mouse control**: Unified mouse control across different operating systems with an abstraction layer. (commit `a7e0732`) 34 | - **Add shadow interactor**: Added support for interacting with elements inside shadow DOM. (commit `6cdea5c`) 35 | 36 | ## 🛠 Core Enhancements 37 | - **Add debug_url spec**: Introduced debug specifications for better error tracking. (commit `4c06c75`) 38 | - **Add client specs**: Comprehensive tests for the core client functionality. (commits `1e17955`, `18f0bf1`) 39 | - **Add config spec**: Added configuration testing specs. (commit `588407f`) 40 | - **Add MPEG for recording**: Integrated MPEG support for screen recording (currently experimental). (commit `d149a14`) 41 | - **Improve arguments priority**: Refined the argument handling mechanism for better control. (commit `9849d0f`) 42 | - **Add Dockerfile for testing**: Included a Docker setup for running tests in isolated environments. (commit `57f334e`) 43 | - **Use xdotool for Linux and add X screenshot**: Enhanced Linux support using `xdotool` for mouse control and added X screenshot capability. (commit `80ef37f`) 44 | - **Improve wait for load**: Improved logic for waiting until the page is fully loaded before proceeding. (commit `28e9371`) 45 | - **Improve DOM events**: Enhanced handling of DOM events for better interaction. (commit `36a425c`) 46 | - **Improve nested elements handling**: Enhanced support for working with nested and complex elements. (commit `ad03ec3`) 47 | - **Get element attributes**: Added method to retrieve element attributes as a hash. (commit `b71e1f7`) 48 | - **Add WEBrick for testing server**: Added a simple WEBrick server for testing purposes. (commit `fc70006`) 49 | - **Improve element interactions**: Various improvements in element manipulation methods. (commit `5430d8c`) 50 | - **Start native mouse support**: Began implementation of native mouse interactions. (commit `fce1aef`) 51 | - **Improve undetection mechanisms**: Enhanced techniques to bypass bot detection. (commit `7ff622a`) 52 | 53 | ## 🐛 Bug Fixes 54 | - **Fix bad stop method**: Corrected an issue with the stopping method that caused unexpected behavior. (commit `4b45d6d`) 55 | - **Fix Docker X size**: Resolved issues with the size of the X window in Docker environments. (commit `41edece`) 56 | 57 | ## 📝 Documentation 58 | - **Update README**: Updated the README file with new instructions and examples. (commit `8118913`) 59 | - **Add logo**: Added a logo to the project for better branding. (commit `2a5cbb9`) 60 | 61 | ## 🧪 CI/CD 62 | - **Enable CI pipeline**: Added continuous integration (CI) setup for automated testing. (commit `4160aa1`) 63 | 64 | ## 🧹 Refactor 65 | - **Rename mouse hardware**: Refactored the naming conventions for mouse-related classes. (commit `89d6ae1`) 66 | - **Remove record notion (temporary)**: Temporarily removed the recording feature for further refinement. (commit `fdb3e0e`) 67 | 68 | ## 🛠 Infrastructure 69 | - **Start adding Xvfb support**: Introduced Xvfb support for headless testing. (commit `678a8a5`) 70 | - **Improve spec servers**: Made enhancements to the testing servers for more robust specs. (commit `51e64f4`) 71 | 72 | ## 🏁 Project Initialization 73 | - **Initialize project**: Set up the initial project structure. (commit `3ec91f4`) 74 | - **Start element development**: Began the implementation of the `Element` class. (commit `adadef0`) -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in chromate.gemspec 6 | gemspec 7 | 8 | gem 'rake', '~> 13.0' 9 | 10 | gem 'rspec', '~> 3.0' 11 | 12 | gem 'rubocop', '~> 1.21' 13 | 14 | gem 'webrick', '~> 1.8' 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | chromate (0.0.7.pre) 5 | ffi (~> 1.17.0) 6 | user_agent_parser (~> 2.18.0) 7 | websocket-client-simple (~> 0.8.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | ast (2.4.2) 13 | diff-lcs (1.5.1) 14 | event_emitter (0.2.6) 15 | ffi (1.17.0) 16 | ffi (1.17.0-x86_64-darwin) 17 | json (2.7.4) 18 | language_server-protocol (3.17.0.3) 19 | parallel (1.26.3) 20 | parser (3.3.5.0) 21 | ast (~> 2.4.1) 22 | racc 23 | racc (1.8.1) 24 | rainbow (3.1.1) 25 | rake (13.2.1) 26 | regexp_parser (2.9.2) 27 | rspec (3.13.0) 28 | rspec-core (~> 3.13.0) 29 | rspec-expectations (~> 3.13.0) 30 | rspec-mocks (~> 3.13.0) 31 | rspec-core (3.13.2) 32 | rspec-support (~> 3.13.0) 33 | rspec-expectations (3.13.3) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.13.0) 36 | rspec-mocks (3.13.2) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.13.0) 39 | rspec-support (3.13.1) 40 | rubocop (1.67.0) 41 | json (~> 2.3) 42 | language_server-protocol (>= 3.17.0) 43 | parallel (~> 1.10) 44 | parser (>= 3.3.0.2) 45 | rainbow (>= 2.2.2, < 4.0) 46 | regexp_parser (>= 2.4, < 3.0) 47 | rubocop-ast (>= 1.32.2, < 2.0) 48 | ruby-progressbar (~> 1.7) 49 | unicode-display_width (>= 2.4.0, < 3.0) 50 | rubocop-ast (1.32.3) 51 | parser (>= 3.3.1.0) 52 | ruby-progressbar (1.13.0) 53 | unicode-display_width (2.6.0) 54 | user_agent_parser (2.18.0) 55 | webrick (1.8.2) 56 | websocket (1.2.11) 57 | websocket-client-simple (0.8.0) 58 | event_emitter 59 | websocket 60 | 61 | PLATFORMS 62 | ruby 63 | x86_64-darwin-21 64 | 65 | DEPENDENCIES 66 | chromate! 67 | rake (~> 13.0) 68 | rspec (~> 3.0) 69 | rubocop (~> 1.21) 70 | webrick (~> 1.8) 71 | 72 | BUNDLED WITH 73 | 2.5.21 74 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Eth3rnit3 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chromate 2 | [![eth3rnit3 - chromate](https://img.shields.io/static/v1?label=eth3rnit3&message=chromate&color=a8a9ad&logo=ruby&labelColor=9b111e)](https://github.com/eth3rnit3/chromate "Go to GitHub repo") 3 | [![GitHub release](https://img.shields.io/github/release/eth3rnit3/chromate?include_prereleases=&sort=semver&color=a8a9ad)](https://github.com/eth3rnit3/chromate/releases/) 4 | [![License](https://img.shields.io/badge/License-MIT-a8a9ad)](#license) 5 | [![Ruby](https://github.com/Eth3rnit3/chromate/actions/workflows/main.yml/badge.svg)](https://github.com/Eth3rnit3/chromate/actions/workflows/main.yml) 6 | [![issues - chromate](https://img.shields.io/github/issues/eth3rnit3/chromate)](https://github.com/eth3rnit3/chromate/issues) 7 | 8 | ![logo](logo.png) 9 | 10 | Chromate is a custom driver for Chrome using the Chrome DevTools Protocol (CDP) to create undetectable bots with human-like behavior. The ultimate goal is to enable the creation of AI agents capable of navigating and performing actions on the web on behalf of the user. This gem is the first step towards achieving that goal. 11 | 12 | ## Installation 13 | 14 | Add gem to your application's Gemfile: 15 | 16 | ```sh 17 | # Github pkgs 18 | bundle add chromate --source https://rubygems.pkg.github.com/eth3rnit3 19 | 20 | # Or add manualy within a block 21 | # source "https://rubygems.pkg.github.com/eth3rnit3" do 22 | # gem "chromate" 23 | # end 24 | 25 | # or 26 | 27 | # Rubygems 28 | bundle add chromate-rb 29 | 30 | ``` 31 | 32 | Or install it yourself as: 33 | 34 | ```sh 35 | # Github pkgs 36 | gem install chromate --source https://rubygems.pkg.github.com/eth3rnit3 37 | 38 | # or 39 | 40 | gem install chromate-rb 41 | ``` 42 | 43 | ## Usage 44 | 45 | [![view - Documentation](https://img.shields.io/badge/view-Documentation-blue?style=for-the-badge)](/docs/ "Go to project documentation") 46 | 47 | ### Basic Example 48 | 49 | ```ruby 50 | require 'chromate' 51 | 52 | browser = Chromate::Browser.new # default headless: true 53 | browser.start 54 | 55 | url = 'http://example.com' 56 | browser.navigate_to(url) 57 | browser.find_element('#some-element').click 58 | browser.screenshot('screenshot.png') 59 | 60 | browser.stop 61 | ``` 62 | 63 | ### Configuration 64 | 65 | You can configure Chromate using a block: 66 | 67 | ```ruby 68 | Chromate.configure do |config| 69 | config.user_data_dir = '/path/to/user/data' 70 | config.headless = true 71 | config.native_control = true 72 | config.proxy = { host: 'proxy.example.com', port: 8080 } 73 | end 74 | ``` 75 | 76 | ## Principle of Operation 77 | 78 | Chromate leverages the Chrome DevTools Protocol (CDP) to interact with the browser. It provides a custom driver that mimics human-like behavior to avoid detection by anti-bot systems. The gem includes native mouse controllers for macOS and Linux, which do not trigger JavaScript events, making interactions more human-like. 79 | 80 | ### Features 81 | 82 | - **Headless Mode**: Run Chrome without a graphical user interface. 83 | - **Native Mouse Control**: Use native mouse events for macOS and Linux. 84 | - **Screenshot Capture**: Capture screenshots of the browser window. 85 | - **Form Interaction**: Fill out and submit forms. 86 | - **Shadow DOM Support**: Interact with elements inside Shadow DOM. 87 | - **Element Interaction**: Click, hover, and type text into elements. 88 | - **Navigation**: Navigate to URLs and wait for page load. 89 | - **Docker xvfb Support**: Dockerfile provided with xvfb setup for easy usage. 90 | 91 | ### Limitations 92 | 93 | - **Windows Support**: Native mouse control is not yet supported on Windows. 94 | - **Headless Mode Complexity**: Requires xvfb for headless mode, adding complexity due to different proportions. 95 | - **Anti-Bot Detection**: Current systems can detect keyboard and mouse interactions via CDP. 96 | 97 | # Native controls and headless 98 | 99 | Chromate provides native mouse control for macOS and Linux, which helps in creating more human-like interactions that are harder to detect by anti-bot systems. However, using native controls in headless mode requires additional setup, such as using xvfb (X Virtual Framebuffer) to simulate a display. 100 | 101 | ## Docker Setup 102 | 103 | To simplify the setup process, Chromate includes a Dockerfile and an entrypoint script that handle the installation and configuration of necessary dependencies, including xvfb. 104 | 105 | ### Dockerfile 106 | 107 | The Dockerfile sets up a minimal environment with all the necessary dependencies to run Chromate in headless mode with xvfb. It installs Chrome, xvfb, and other required libraries. 108 | 109 | The [entrypoint](dockerfiles/docker-entrypoint.sh) script ensures that xvfb is running before starting the main process. It removes any existing lock files, starts xvfb and a window manager (fluxbox), and waits for xvfb to initialize. 110 | 111 | ### Example Docker Usage 112 | 113 | Here is an example of how you can use Chromate inside a Docker container: 114 | 115 | ```sh 116 | # Build the Docker image 117 | docker build -f dockerfiles/Dockerfile -t chromate . 118 | 119 | # Run the Docker container 120 | docker run -v $(pwd):/app -it chromate 121 | 122 | # Inside the container, run your Ruby script 123 | ruby your_script.rb # or bundle exec rspec 124 | ``` 125 | 126 | This setup ensures that all necessary dependencies are installed and configured correctly, allowing you to focus on writing your automation scripts without worrying about the underlying environment. 127 | 128 | ## Disclaimer 129 | 130 | **Chromate** is an open-source project designed to provide a solid foundation for the creation of autonomous AI agents, fostering innovation and development in the field of artificial intelligence. This project is developed with an educational and collaborative spirit, aiming to promote responsible and ethical usage. 131 | 132 | Under no circumstances is **Chromate** intended to be used for creating automated bots, scraping tools, or any other activities that violate platform policies or applicable laws. The author disclaims any responsibility for improper or illegal use of this project. 133 | 134 | Users are encouraged to comply with platform terms of service and to adopt practices that adhere to ethical and legal standards. 135 | 136 | ## Contribution 137 | 138 | Contributions are welcome! If you have suggestions for improvements or new features, please open an issue or submit a pull request on GitHub. 139 | 140 | ### How to Contribute 141 | 142 | 1. Fork the repository. 143 | 2. Create a new branch (`git checkout -b feature-branch`). 144 | 3. Make your changes. 145 | 4. Commit your changes (`git commit -am 'Add new feature'`). 146 | 5. Push to the branch (`git push origin feature-branch`). 147 | 6. Create a new Pull Request. 148 | 149 | ## License 150 | 151 | Released under [MIT](/LICENSE.txt) by [@eth3rnit3](https://github.com/eth3rnit3). -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | 14 | require_relative "lib/chromate" 15 | require_relative 'spec/support/modes' 16 | 17 | namespace :chromate do 18 | namespace :test do 19 | include Support::Modes 20 | 21 | task :open do 22 | browser = Chromate::Browser.new(headless: false) 23 | browser.start 24 | browser.navigate_to("https://2captcha.com/fr/demo/recaptcha-v2") 25 | sleep 2 26 | element = browser.find_element("#root") 27 | binding.irb 28 | browser.stop 29 | end 30 | 31 | # Xfvb mode 32 | # docker run -it --rm -v $(pwd):/app --env CHROMATE_MODE=docker-xvfb chromate:latest bundle exec rake chromate:test:all 33 | 34 | # BotBrowser mode 35 | # docker run -it --rm -v $(pwd):/app --env CHROMATE_MODE=bot-browser chromate:latest bundle exec rake chromate:test:all 36 | 37 | # Default mode 38 | # docker run -it --rm -v $(pwd):/app chromate:latest bundle exec rake chromate:test:all 39 | task :all do 40 | Rake::Task["chromate:test:pixelscan"].invoke 41 | Rake::Task["chromate:test:brotector"].invoke 42 | Rake::Task["chromate:test:bot"].invoke 43 | Rake::Task["chromate:test:cloudflare"].invoke 44 | end 45 | 46 | task :pixelscan do 47 | browser = Chromate::Browser.new(browser_args) 48 | browser.start 49 | browser.navigate_to("https://pixelscan.net") 50 | sleep 10 51 | browser.screenshot("results/pixelscan.png") 52 | browser.stop 53 | end 54 | 55 | task :brotector do 56 | browser = Chromate::Browser.new(browser_args) 57 | browser.start 58 | browser.navigate_to("https://kaliiiiiiiiii.github.io/brotector") 59 | sleep 2 60 | browser.find_element("#clickHere").click 61 | sleep 3 62 | browser.screenshot("results/brotector.png") 63 | browser.stop 64 | end 65 | 66 | task :bot do 67 | browser = Chromate::Browser.new(browser_args) 68 | browser.start 69 | browser.navigate_to("https://bot.sannysoft.com") 70 | sleep 2 71 | browser.screenshot("results/bot.png") 72 | browser.stop 73 | end 74 | 75 | task :cloudflare do 76 | browser = Chromate::Browser.new(browser_args) 77 | browser.start 78 | browser.navigate_to("https://2captcha.com/fr/demo/cloudflare-turnstile-challenge") 79 | sleep 10 80 | browser.screenshot("results/cloudflare.png") 81 | browser.stop 82 | end 83 | 84 | task :my_ip do 85 | browser = Chromate::Browser.new(browser_args) 86 | browser.start 87 | browser.navigate_to("https://whatismyipaddress.com") 88 | sleep 2 89 | browser.find_element('//*[@id="qc-cmp2-ui"]/div[2]/div/button[3]').click 90 | browser.screenshot("results/my_ip.png") 91 | browser.stop 92 | end 93 | 94 | task :headers do 95 | browser = Chromate::Browser.new(browser_args) 96 | browser.start 97 | browser.navigate_to("https://httpbin.org/headers") 98 | sleep 2 99 | browser.screenshot("results/headers.png") 100 | browser.stop 101 | end 102 | end 103 | end -------------------------------------------------------------------------------- /bin/archive: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | tar -czvf chromate.tar.gz lib docs spec/**/*_spec.rb Gemfile Gemfile.lock .ruby-version README.md LICENSE.txt 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "chromate" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /chromate.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/chromate/version' 4 | 5 | mode = ENV.fetch('DEPLOY_MODE', 'github') 6 | host = mode == 'github' ? 'https://rubygems.pkg.github.com/Eth3rnit3' : 'https://rubygems.org' 7 | name = mode == 'github' ? 'chromate' : 'chromate-rb' 8 | 9 | Gem::Specification.new do |spec| 10 | spec.name = name 11 | spec.version = Chromate::VERSION 12 | spec.authors = ['Eth3rnit3'] 13 | spec.email = ['eth3rnit3@gmail.com'] 14 | 15 | spec.summary = 'Chromate is a Ruby library to control Google Chrome with the Chrome DevTools Protocol.' 16 | spec.description = 'Chromate is a Ruby library to control Google Chrome with the Chrome DevTools Protocol.' 17 | spec.homepage = 'http://github.com/Eth3rnit3/chromate' 18 | spec.license = 'MIT' 19 | spec.required_ruby_version = '>= 3.0.0' 20 | 21 | spec.metadata['allowed_push_host'] = host 22 | 23 | spec.metadata['homepage_uri'] = spec.homepage 24 | spec.metadata['source_code_uri'] = 'https://github.com/Eth3rnit3/chromate' 25 | spec.metadata['changelog_uri'] = 'https://github.com/Eth3rnit3/chromate/blob/main/CHANGELOG.md' 26 | 27 | # Specify which files should be added to the gem when it is released. 28 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 29 | gemspec = File.basename(__FILE__) 30 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 31 | ls.readlines("\x0", chomp: true).reject do |f| 32 | (f == gemspec) || 33 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 34 | end 35 | end 36 | spec.bindir = 'exe' 37 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 38 | spec.require_paths = ['lib'] 39 | 40 | # Uncomment to register a new dependency of your gem 41 | # spec.add_dependency "example-gem", "~> 1.0" 42 | spec.add_dependency 'ffi', '~> 1.17.0' 43 | spec.add_dependency 'user_agent_parser', '~> 2.18.0' 44 | spec.add_dependency 'websocket-client-simple', '~> 0.8.0' 45 | 46 | # For more information and examples about making a new gem, check out our 47 | # guide at: https://bundler.io/guides/creating_gem.html 48 | spec.metadata['rubygems_mfa_required'] = 'true' 49 | end 50 | -------------------------------------------------------------------------------- /docker_root/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'chromate', path: '/chromate' 4 | gem 'webrick' 5 | -------------------------------------------------------------------------------- /docker_root/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: /chromate 3 | specs: 4 | chromate (0.0.1.pre) 5 | ffi (~> 1.17.0) 6 | websocket-client-simple (~> 0.8.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | event_emitter (0.2.6) 12 | ffi (1.17.0) 13 | webrick (1.8.2) 14 | websocket (1.2.11) 15 | websocket-client-simple (0.8.0) 16 | event_emitter 17 | websocket 18 | 19 | PLATFORMS 20 | ruby 21 | x86_64-linux 22 | 23 | DEPENDENCIES 24 | chromate! 25 | webrick 26 | 27 | BUNDLED WITH 28 | 2.5.21 29 | -------------------------------------------------------------------------------- /docker_root/TestInDocker.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/docker_root/TestInDocker.gif -------------------------------------------------------------------------------- /docker_root/app.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'chromate' 3 | require '/chromate/spec/support/server' 4 | 5 | class TestInDocker 6 | include Support::Server 7 | 8 | attr_reader :browser 9 | 10 | def initialize 11 | start_servers 12 | 13 | @browser = Chromate::Browser.new(headless: false, xfvb: true, record: 'record.mp4', native_control: false) 14 | @browser.start 15 | 16 | trap('INT') { stop } 17 | trap('TERM') { stop } 18 | 19 | at_exit { stop } 20 | end 21 | 22 | def stop 23 | @browser.stop 24 | stop_servers 25 | # Convert the video to a gif 26 | pid = spawn('ffmpeg -i record.mp4 -vf "fps=10,scale=640:-1:flags=lanczos,palettegen" palette.png') 27 | Process.wait(pid) 28 | pid = spawn('ffmpeg -i record.mp4 -i palette.png -filter_complex "fps=10,scale=640:-1:flags=lanczos[x];[x][1:v]paletteuse" TestInDocker.gif') 29 | Process.wait(pid) 30 | end 31 | 32 | def run 33 | click_features 34 | move_features 35 | drag_and_drop_features 36 | fill_form_features 37 | shadow_dom_features 38 | end 39 | 40 | def click_features 41 | url = server_urls['where_clicked'] 42 | browser.navigate_to(url) 43 | browser.find_element('#interactive-button').click 44 | sleep 1 45 | end 46 | 47 | def move_features 48 | url = server_urls['where_moved'] 49 | browser.navigate_to(url) 50 | browser.find_element('#red').hover 51 | browser.find_element('#yellow').hover 52 | browser.find_element('#green').hover 53 | browser.find_element('#blue').hover 54 | sleep 1 55 | end 56 | 57 | def drag_and_drop_features 58 | url = server_urls['drag_and_drop'] 59 | browser.navigate_to(url) 60 | blue_square = browser.find_element('#draggable') 61 | green_square = browser.find_element('#dropzone') 62 | blue_square.drop_to(green_square) 63 | sleep 1 64 | end 65 | 66 | def fill_form_features 67 | url = server_urls['fill_form'] 68 | browser.navigate_to(url) 69 | browser.find_element('#first-name').type('John') 70 | browser.find_element('#last-name').type('Doe') 71 | browser.find_element('#gender').select_option('female') 72 | browser.find_element('#option-2').click 73 | browser.find_element('#submit-button').click 74 | sleep 1 75 | end 76 | 77 | def shadow_dom_features 78 | url = server_urls['shadow_checkbox'] 79 | browser.navigate_to(url) 80 | shadow_container = browser.find_element('#shadow-container') 81 | checkbox = shadow_container.find_shadow_child('#shadow-checkbox') 82 | checkbox.click 83 | sleep 1 84 | end 85 | end 86 | 87 | TestInDocker.new.run 88 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 ruby:3.2-slim 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | nano \ 5 | xvfb \ 6 | curl \ 7 | git \ 8 | bash \ 9 | build-essential \ 10 | libx11-xcb1 \ 11 | libxcomposite1 \ 12 | libxrandr2 \ 13 | libxdamage1 \ 14 | libjpeg62-turbo \ 15 | libwebp-dev \ 16 | udev \ 17 | fonts-freefont-ttf \ 18 | fonts-noto-color-emoji \ 19 | nodejs \ 20 | npm \ 21 | libxcursor1 \ 22 | libgtk-3-0 \ 23 | libpangocairo-1.0-0 \ 24 | libcairo-gobject2 \ 25 | libgdk-pixbuf2.0-0 \ 26 | libgstreamer1.0-0 \ 27 | libgstreamer-plugins-base1.0-0 \ 28 | gstreamer1.0-gl \ 29 | gstreamer1.0-libav \ 30 | gstreamer1.0-plugins-bad \ 31 | libxslt1.1 \ 32 | libwoff1 \ 33 | libvpx7 \ 34 | libevent-2.1-7 \ 35 | libopus0 \ 36 | libsecret-1-0 \ 37 | libenchant-2-2 \ 38 | libharfbuzz-icu0 \ 39 | libhyphen0 \ 40 | libmanette-0.2-0 \ 41 | libflite1 \ 42 | libavcodec-extra \ 43 | libx11-dev \ 44 | libxtst-dev \ 45 | x11-utils \ 46 | x11-apps \ 47 | imagemagick \ 48 | xdotool \ 49 | fluxbox \ 50 | ffmpeg \ 51 | psmisc \ 52 | --no-install-recommends && rm -rf /var/lib/apt/lists/* 53 | 54 | # Préparer le système 55 | RUN apt-get update && apt-get install -y wget gnupg2 lsb-release && rm -rf /var/lib/apt/lists/* 56 | 57 | # Télécharger et ajouter la clé GPG 58 | RUN wget -q -O- https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg 59 | 60 | # Ajouter le dépôt Google Chrome 61 | RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list 62 | 63 | # Mise à jour et installation de Google Chrome 64 | RUN apt-get update && apt-get install -y google-chrome-stable 65 | 66 | ENV CHROME_BIN=/usr/bin/google-chrome 67 | 68 | RUN gem install bundler -v 2.5.21 69 | 70 | WORKDIR /chromate 71 | 72 | COPY Gemfile Gemfile.lock chromate.gemspec ./ 73 | COPY lib/chromate/version.rb ./lib/chromate/version.rb 74 | 75 | RUN bundle install 76 | 77 | COPY dockerfiles/docker-entrypoint.sh /usr/local/bin/ 78 | 79 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 80 | 81 | COPY . . 82 | 83 | ENV DISPLAY=:99 84 | 85 | WORKDIR /app 86 | 87 | COPY docker_root/ /app/ 88 | 89 | ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] 90 | 91 | CMD [ "bash" ] 92 | 93 | # docker build -f dockerfiles/Dockerfile -t chromate . 94 | 95 | # Run the container for testing chromate 96 | # docker run -v $(pwd)/docker_root:/app -it chromate 97 | 98 | # Run the container for development 99 | # docker run -v $(pwd):/app -it chromate -------------------------------------------------------------------------------- /dockerfiles/README.md: -------------------------------------------------------------------------------- 1 | # Chromate Docker Configuration 2 | 3 | This section explains how to configure and test the Chromate gem using Docker with xvfb support. 4 | 5 | ![TestInDocker.gif](../docker_root/TestInDocker.gif) 6 | 7 | ## Docker Setup 8 | 9 | To simplify the setup process, Chromate includes a Dockerfile and an entrypoint script that handle the installation and configuration of necessary dependencies, including xvfb. 10 | 11 | ### Dockerfile 12 | 13 | The Dockerfile sets up a minimal environment with all the necessary dependencies to run Chromate in headless mode with xvfb. It installs Chrome, xvfb, and other required libraries. 14 | 15 | ### 16 | 17 | docker-entrypoint.sh 18 | 19 | 20 | 21 | The entrypoint script ensures that xvfb is running before starting the main process. It removes any existing lock files, starts xvfb and a window manager (fluxbox), and waits for xvfb to initialize. 22 | 23 | ### How to Use 24 | 25 | 1. **Build the Docker Image** 26 | 27 | ```sh 28 | docker build -f dockerfiles/Dockerfile -t chromate . 29 | ``` 30 | 31 | 2. **Run the Docker Container** 32 | 33 | ```sh 34 | docker run -v $(pwd)/docker_root:/app -it chromate 35 | ``` 36 | 37 | This command mounts the current directory to `/app` inside the container and starts an interactive bash session. 38 | 39 | 3. **Run the Test Script** 40 | 41 | Inside the Docker container, run the test script: 42 | 43 | ```sh 44 | ruby app.rb 45 | ``` 46 | 47 | ## Conclusion 48 | 49 | This setup ensures that all necessary dependencies are installed and configured correctly, allowing you to focus on writing your automation scripts without worrying about the underlying environment. -------------------------------------------------------------------------------- /dockerfiles/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -f /tmp/.X99-lock ]; then 4 | rm -f /tmp/.X99-lock 5 | fi 6 | 7 | if ! pgrep -x "Xvfb" > /dev/null; then 8 | Xvfb :99 -screen 0 1920x1080x24 & 9 | DISPLAY=:99 fluxbox & 10 | fi 11 | 12 | echo "Waiting for Xvfb to start..." 13 | sleep 1 14 | 15 | exec "$@" 16 | -------------------------------------------------------------------------------- /docs/BOT_BROWSER.md: -------------------------------------------------------------------------------- 1 | # BotBrowser Documentation 2 | 3 | BotBrowser is a specialized configuration module for Chromate that helps you manage and use a specific Chrome browser installation for bot automation purposes. 4 | 5 | ## Table of Contents 6 | 7 | 1. [Installation](#installation) 8 | 2. [Usage](#usage) 9 | 3. [Configuration](#configuration) 10 | 4. [API Reference](#api-reference) 11 | 12 | ## Installation 13 | 14 | First, require the BotBrowser module in your Ruby code: 15 | 16 | ```ruby 17 | require 'bot_browser' 18 | ``` 19 | 20 | Then, install the browser: 21 | 22 | ```ruby 23 | # Install the latest version 24 | BotBrowser.install 25 | 26 | # Or install a specific version 27 | BotBrowser.install('v130') 28 | ``` 29 | 30 | ## Usage 31 | 32 | Here's a basic example of how to use BotBrowser with Chromate: 33 | 34 | ```ruby 35 | require 'bot_browser' 36 | 37 | # Install the browser if not already installed 38 | BotBrowser.install unless BotBrowser.installed? 39 | 40 | # Load the BotBrowser configuration 41 | BotBrowser.load 42 | 43 | # Create a new browser instance 44 | browser = Chromate::Browser.new 45 | 46 | # Use the browser as you would normally with Chromate 47 | browser.navigate_to('https://example.com') 48 | ``` 49 | 50 | ## Configuration 51 | 52 | BotBrowser uses a configuration file located at `~/.botbrowser/config.yml`. This file is automatically created during installation and contains: 53 | 54 | - `bot_browser_path`: Path to the Chrome binary 55 | - `profile`: Path to the browser profile directory 56 | 57 | ## API Reference 58 | 59 | ### `BotBrowser.install(version = nil)` 60 | Installs the Chrome browser for bot automation. 61 | - `version`: Optional. Specific version to install. If not provided, installs the latest version. 62 | 63 | ### `BotBrowser.uninstall` 64 | Removes the installed browser and its configuration. 65 | 66 | ### `BotBrowser.installed?` 67 | Checks if the browser is installed. 68 | - Returns: `true` if installed, `false` otherwise. 69 | 70 | ### `BotBrowser.load` 71 | Loads the BotBrowser configuration and sets up Chromate with the appropriate settings. 72 | - Configures Chrome binary path 73 | - Sets up browser profile 74 | - Configures necessary Chrome flags for bot automation 75 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Chromate Documentation Index 2 | 3 | Welcome to the Chromate Documentation! This index provides an overview of the main classes and their detailed documentation, covering public methods, usage examples, and key features. 4 | 5 | ## 📚 Table of Contents 6 | 7 | 1. [Introduction](#introduction) 8 | 2. [Classes Overview](#classes-overview) 9 | 3. [Detailed Documentation](#detailed-documentation) 10 | - [Chromate::Browser](#1-chromatebrowser-class) 11 | - [Chromate::Element](#2-chromateelement-class) 12 | 13 | --- 14 | 15 | ## Introduction 16 | 17 | Chromate is a Ruby-based library designed to interact with the Chrome DevTools Protocol (CDP). It allows you to control a headless Chrome browser, manipulate DOM elements, and perform automated tasks such as navigation, screenshot capture, and form submissions. 18 | 19 | This documentation provides a comprehensive guide for the core components of Chromate: 20 | 21 | - **Chromate::Browser**: Manages the browser instance, providing methods for starting, stopping, navigation, and executing browser actions. 22 | - **Chromate::Element**: Represents a DOM element, enabling actions like clicking, typing, and attribute manipulation. 23 | 24 | ## Classes Overview 25 | 26 | | Class | Description | 27 | | ------------------ | ----------------------------------------------------- | 28 | | `Chromate::Browser`| A class for controlling the Chrome browser instance. | 29 | | `Chromate::Element`| A class for interacting with individual DOM elements. | 30 | 31 | ## Detailed Documentation 32 | 33 | ### 1. `Chromate::Browser` Class 34 | 35 | The `Browser` class is responsible for managing the lifecycle of a Chrome browser instance. It provides methods for navigation, screenshot capture, and DOM interaction. It also includes features for headless mode, native control, and video recording of sessions. 36 | 37 | - **Features:** 38 | - Start and stop the browser instance. 39 | - Navigate to URLs, refresh, and go back in browser history. 40 | - Capture full-page and Xvfb screenshots. 41 | - Execute JavaScript code and interact with DOM elements. 42 | 43 | For the full documentation and usage examples, refer to [Chromate::Browser Documentation](#chromatebrowser-class). 44 | 45 | ### 2. `Chromate::Element` Class 46 | 47 | The `Element` class provides a robust interface for interacting with DOM elements in the browser. It includes methods for text extraction, attribute manipulation, and user interaction simulation (click, hover, type). 48 | 49 | - **Features:** 50 | - Retrieve text and HTML content of an element. 51 | - Set and get element attributes. 52 | - Simulate user interactions (click, hover, type). 53 | - Interact with shadow DOM elements. 54 | 55 | For the full documentation and usage examples, refer to [Chromate::Element Documentation](#chromateelement-class). 56 | 57 | --- 58 | 59 | ## How to Get Started 60 | 61 | To start using Chromate in your project, ensure that you have a working installation of Google Chrome and Ruby. Follow the setup instructions provided in the README file of the project. 62 | 63 | Example usage: 64 | 65 | ```ruby 66 | require 'chromate' 67 | 68 | browser = Chromate::Browser.new(headless: true) 69 | browser.start 70 | browser.navigate_to('https://example.com') 71 | element = browser.find_element('#header') 72 | puts element.text 73 | browser.stop 74 | ``` -------------------------------------------------------------------------------- /docs/browser.md: -------------------------------------------------------------------------------- 1 | ## `Chromate::Browser` Class 2 | 3 | The `Chromate::Browser` class is responsible for controlling a browser instance using the Chrome DevTools Protocol (CDP). It provides methods for navigation, screenshots, and DOM interactions, as well as handling browser lifecycle (start and stop). 4 | 5 | ### Initialization 6 | 7 | ```ruby 8 | browser = Chromate::Browser.new(options = {}) 9 | ``` 10 | 11 | - **Parameters:** 12 | - `options` (Hash, optional): Configuration options for the browser instance. 13 | - `:chrome_path` (String): Path to the Chrome executable. 14 | - `:user_data_dir` (String): Directory for storing user data (default: a temporary directory). 15 | - `:headless` (Boolean): Run the browser in headless mode. 16 | - `:xfvb` (Boolean): Use Xvfb for headless mode on Linux. 17 | - `:native_control` (Boolean): Enable native control for enhanced undetection. 18 | - `:record` (Boolean): Enable video recording of the browser session. 19 | 20 | ### Public Methods 21 | 22 | #### `#start` 23 | 24 | Starts the browser process and initializes the CDP client. 25 | 26 | - **Example:** 27 | ```ruby 28 | browser.start 29 | ``` 30 | 31 | #### `#stop` 32 | 33 | Stops the browser process, including any associated Xvfb or video recording processes. 34 | 35 | - **Example:** 36 | ```ruby 37 | browser.stop 38 | ``` 39 | 40 | #### `#native_control?` 41 | 42 | Checks if native control is enabled for the browser instance. 43 | 44 | - **Returns:** 45 | - `Boolean`: `true` if native control is enabled, `false` otherwise. 46 | 47 | - **Example:** 48 | ```ruby 49 | puts "Native control enabled" if browser.native_control? 50 | ``` 51 | 52 | ### Navigation Methods (from `Actions::Navigate`) 53 | 54 | #### `#navigate_to(url)` 55 | 56 | Navigates the browser to the specified URL. 57 | 58 | - **Parameters:** 59 | - `url` (String): The URL to navigate to. 60 | 61 | - **Example:** 62 | ```ruby 63 | browser.navigate_to('https://example.com') 64 | ``` 65 | 66 | #### `#wait_for_page_load` 67 | 68 | Waits until the page has fully loaded, including the `DOMContentLoaded` event, `load` event, and `frameStoppedLoading` event. 69 | 70 | - **Example:** 71 | ```ruby 72 | browser.wait_for_page_load 73 | ``` 74 | 75 | #### `#refresh` 76 | 77 | Reloads the current page. 78 | 79 | - **Example:** 80 | ```ruby 81 | browser.refresh 82 | ``` 83 | 84 | #### `#go_back` 85 | 86 | Navigates back to the previous page in the browser history. 87 | 88 | - **Example:** 89 | ```ruby 90 | browser.go_back 91 | ``` 92 | 93 | ### Screenshot Methods (from `Actions::Screenshot`) 94 | 95 | #### `#screenshot(file_path, options = {})` 96 | 97 | Takes a screenshot of the current page and saves it to the specified file. 98 | 99 | - **Parameters:** 100 | - `file_path` (String, optional): The file path to save the screenshot. 101 | - `options` (Hash, optional): Additional options for the screenshot. 102 | - - `full_page` (Boolean, optional): Take a full page screenshot 103 | 104 | It will call `#xvfb_screenshot` private method if `xvfb` mode is `true` 105 | 106 | - **Example:** 107 | ```ruby 108 | browser.screenshot('screenshot.png') 109 | ``` 110 | 111 | ### DOM Methods (from `Actions::Dom`) 112 | 113 | #### `#find_element(selector)` 114 | 115 | Finds a single element on the page using the specified CSS selector. Returns a specialized element class based on the element type: 116 | 117 | - **Parameters:** 118 | - `selector` (String): The CSS selector to locate the element. 119 | 120 | - **Returns:** 121 | - `Chromate::Elements::Select`: For ``) 124 | - `Chromate::Elements::Checkbox`: For checkbox inputs (``) 125 | - `Chromate::Element`: For all other element types 126 | 127 | Each specialized element type provides specific methods for interacting with that type of element. For example: 128 | 129 | ```ruby 130 | # Working with radio buttons 131 | radio = browser.find_element('input[type="radio"]') 132 | radio.check if !radio.checked? 133 | 134 | # Working with checkboxes 135 | checkbox = browser.find_element('input[type="checkbox"]') 136 | checkbox.toggle 137 | 138 | # Working with select elements 139 | select = browser.find_element('select#country') 140 | select.select_option('France') 141 | ``` 142 | 143 | See the [Element documentation](element.md) for more details about specialized elements. 144 | 145 | #### `#evaluate_script(script)` 146 | 147 | Executes the specified JavaScript expression on the page. 148 | 149 | - **Parameters:** 150 | - `script` (String): The JavaScript code to evaluate. 151 | 152 | - **Returns:** 153 | - The result of the JavaScript evaluation. 154 | 155 | - **Example:** 156 | ```ruby 157 | result = browser.evaluate_script('document.title') 158 | puts "Page title: #{result}" 159 | ``` 160 | 161 | ### Exception Handling 162 | 163 | - The browser handles `INT` and `TERM` signals gracefully by stopping the browser process and exiting safely. 164 | - The `stop_and_exit` method is used to ensure proper shutdown. 165 | 166 | ### Example Usage 167 | 168 | ```ruby 169 | require 'chromate' 170 | 171 | options = { 172 | chrome_path: '/usr/bin/google-chrome', 173 | headless: true, 174 | native_control: true, 175 | record: true 176 | } 177 | 178 | browser = Chromate::Browser.new(options) 179 | 180 | browser.start 181 | browser.navigate_to('https://example.com') 182 | browser.screenshot('example.png') 183 | element = browser.find_element('#main-header') 184 | puts element.text 185 | browser.stop -------------------------------------------------------------------------------- /docs/client.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | The `Chromate::Client` class is responsible for managing WebSocket connections to Chrome DevTools Protocol (CDP). It handles communication between the Chromate library and the Chrome browser, including message sending, receiving, and event handling. 4 | 5 | ### Initialization 6 | 7 | ```ruby 8 | client = Chromate::Client.new(browser) 9 | ``` 10 | 11 | - **Parameters:** 12 | - `browser` (Chromate::Browser): The browser instance to connect to. 13 | 14 | ### Public Methods 15 | 16 | #### `#start` 17 | 18 | Establishes the WebSocket connection to Chrome DevTools Protocol and sets up event handlers. 19 | 20 | - **Returns:** 21 | - `self`: Returns the client instance for method chaining. 22 | 23 | - **Example:** 24 | ```ruby 25 | client.start 26 | ``` 27 | 28 | #### `#stop` 29 | 30 | Closes the WebSocket connection. 31 | 32 | - **Returns:** 33 | - `self`: Returns the client instance for method chaining. 34 | 35 | - **Example:** 36 | ```ruby 37 | client.stop 38 | ``` 39 | 40 | #### `#send_message(method, params = {})` 41 | 42 | Sends a message to Chrome DevTools Protocol and waits for the response. 43 | 44 | - **Parameters:** 45 | - `method` (String): The CDP method to call. 46 | - `params` (Hash, optional): Parameters for the CDP method. 47 | 48 | - **Returns:** 49 | - `Hash`: The response from Chrome DevTools Protocol. 50 | 51 | - **Example:** 52 | ```ruby 53 | result = client.send_message('DOM.getDocument') 54 | ``` 55 | 56 | #### `#reconnect` 57 | 58 | Reestablishes the WebSocket connection if it was lost. 59 | 60 | - **Returns:** 61 | - `self`: Returns the client instance for method chaining. 62 | 63 | - **Example:** 64 | ```ruby 65 | client.reconnect 66 | ``` 67 | 68 | #### `#on_message` 69 | 70 | Subscribes to WebSocket messages. Allows different parts of the application to listen for CDP events. 71 | 72 | - **Parameters:** 73 | - `&block` (Block): The block to execute when a message is received. 74 | 75 | - **Example:** 76 | ```ruby 77 | client.on_message do |message| 78 | puts "Received message: #{message}" 79 | end 80 | ``` 81 | 82 | ### Class Methods 83 | 84 | #### `.listeners` 85 | 86 | Returns the array of registered message listeners. 87 | 88 | - **Returns:** 89 | - `Array`: The array of listener blocks. 90 | 91 | ### Event Handling 92 | 93 | The client automatically handles several WebSocket events: 94 | 95 | - `:message`: Processes incoming CDP messages and notifies listeners 96 | - `:open`: Logs successful connection 97 | - `:error`: Logs WebSocket errors 98 | - `:close`: Logs connection closure 99 | 100 | ### Error Handling 101 | 102 | The client includes automatic reconnection logic when message sending fails: 103 | 104 | - Attempts to reconnect to the WebSocket 105 | - Retries the failed message 106 | - Logs errors and debug information through `Chromate::CLogger` 107 | 108 | ### Example Usage 109 | 110 | ```ruby 111 | browser = Chromate::Browser.new 112 | client = Chromate::Client.new(browser) 113 | 114 | client.start 115 | 116 | # Send a CDP command 117 | result = client.send_message('DOM.getDocument') 118 | 119 | # Listen for specific events 120 | client.on_message do |msg| 121 | puts msg if msg['method'] == 'DOM.documentUpdated' 122 | end 123 | 124 | # Clean up 125 | client.stop 126 | ``` 127 | -------------------------------------------------------------------------------- /docs/elements/checkbox.md: -------------------------------------------------------------------------------- 1 | # Checkbox Element 2 | 3 | The `Chromate::Elements::Checkbox` class represents a checkbox input element in the browser. It extends the base `Element` class with specific functionality for checkboxes. 4 | 5 | ### Initialization 6 | 7 | ```ruby 8 | checkbox = Chromate::Elements::Checkbox.new(selector, client, **options) 9 | ``` 10 | 11 | - **Parameters:** 12 | - `selector` (String): The CSS selector used to locate the checkbox. 13 | - `client` (Chromate::Client): An instance of the CDP client. 14 | - `options` (Hash): Additional options passed to the Element constructor. 15 | - `object_id` (String): Optional. The object ID of a pre-searched element. 16 | - `node_id` (Integer): Optional. The node ID of a pre-searched element. 17 | - `root_id` (Integer): Optional. The root ID of a pre-searched element. 18 | 19 | ### Public Methods 20 | 21 | #### `#checked?` 22 | 23 | Returns whether the checkbox is currently checked. 24 | 25 | - **Returns:** 26 | - `Boolean`: `true` if the checkbox is checked, `false` otherwise. 27 | 28 | - **Example:** 29 | ```ruby 30 | if checkbox.checked? 31 | puts "Checkbox is checked" 32 | end 33 | ``` 34 | 35 | #### `#check` 36 | 37 | Checks the checkbox if it's not already checked. 38 | 39 | - **Returns:** 40 | - `self`: Returns the checkbox element for method chaining. 41 | 42 | - **Example:** 43 | ```ruby 44 | checkbox.check 45 | ``` 46 | 47 | #### `#uncheck` 48 | 49 | Unchecks the checkbox if it's currently checked. 50 | 51 | - **Returns:** 52 | - `self`: Returns the checkbox element for method chaining. 53 | 54 | - **Example:** 55 | ```ruby 56 | checkbox.uncheck 57 | ``` 58 | 59 | #### `#toggle` 60 | 61 | Toggles the checkbox state (checks if unchecked, unchecks if checked). 62 | 63 | - **Returns:** 64 | - `self`: Returns the checkbox element for method chaining. 65 | 66 | - **Example:** 67 | ```ruby 68 | checkbox.toggle 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/elements/radio.md: -------------------------------------------------------------------------------- 1 | # Radio Element 2 | 3 | The `Chromate::Elements::Radio` class represents a radio button input element in the browser. It extends the base `Element` class with specific functionality for radio buttons. 4 | 5 | ### Initialization 6 | 7 | ```ruby 8 | radio = Chromate::Elements::Radio.new(selector, client, **options) 9 | ``` 10 | 11 | - **Parameters:** 12 | - `selector` (String): The CSS selector used to locate the radio button. 13 | - `client` (Chromate::Client): An instance of the CDP client. 14 | - `options` (Hash): Additional options passed to the Element constructor. 15 | - `object_id` (String): Optional. The object ID of a pre-searched element. 16 | - `node_id` (Integer): Optional. The node ID of a pre-searched element. 17 | - `root_id` (Integer): Optional. The root ID of a pre-searched element. 18 | 19 | ### Public Methods 20 | 21 | #### `#checked?` 22 | 23 | Returns whether the radio button is currently checked. 24 | 25 | - **Returns:** 26 | - `Boolean`: `true` if the radio button is checked, `false` otherwise. 27 | 28 | - **Example:** 29 | ```ruby 30 | if radio.checked? 31 | puts "Radio button is checked" 32 | end 33 | ``` 34 | 35 | #### `#check` 36 | 37 | Checks the radio button if it's not already checked. 38 | 39 | - **Returns:** 40 | - `self`: Returns the radio element for method chaining. 41 | 42 | - **Example:** 43 | ```ruby 44 | radio.check 45 | ``` 46 | 47 | #### `#uncheck` 48 | 49 | Unchecks the radio button if it's currently checked. 50 | 51 | - **Returns:** 52 | - `self`: Returns the radio element for method chaining. 53 | 54 | - **Example:** 55 | ```ruby 56 | radio.uncheck 57 | ``` 58 | -------------------------------------------------------------------------------- /lib/bot_browser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | require 'chromate/c_logger' 5 | require 'bot_browser/installer' 6 | 7 | module BotBrowser 8 | class << self 9 | def install(version = nil) 10 | Installer.install(version) 11 | end 12 | 13 | def uninstall 14 | Installer.uninstall 15 | end 16 | 17 | def installed? 18 | Installer.installed? 19 | end 20 | 21 | def load 22 | yaml = YAML.load_file("#{Dir.home}/.botbrowser/config.yml") 23 | 24 | Chromate.configure do |config| 25 | ENV['CHROME_BIN'] = yaml['bot_browser_path'] 26 | config.args = [ 27 | "--bot-profile=#{yaml["profile"]}", 28 | '--no-sandbox' 29 | ] 30 | config.startup_patch = false 31 | end 32 | 33 | Chromate::CLogger.log('BotBrowser loaded', level: :debug) 34 | end 35 | end 36 | end 37 | 38 | # Usage 39 | # require 'bot_browser' 40 | 41 | # BotBrowser.install 42 | # BotBrowser.load 43 | # browser = Chromate::Browser.new 44 | -------------------------------------------------------------------------------- /lib/bot_browser/downloader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Special thanks to the BotBrowser project (https://github.com/MiddleSchoolStudent/BotBrowser) 4 | # for providing an amazing foundation for browser automation and making this work possible. 5 | 6 | require 'chromate/binary' 7 | 8 | module BotBrowser 9 | class Downloader 10 | class << self 11 | def download(version = nil, profile = nil, platform = :mac) 12 | version ||= versions.keys.first 13 | profile ||= profiles[version].keys.first 14 | version = version.to_sym 15 | binary_path = download_file(versions[version][platform], "/tmp/botbrowser_#{version}_#{platform}.#{extension(platform)}") 16 | profile_path = download_file(profiles[version][profile], "/tmp/botbrowser_#{version}_#{platform}.json") 17 | 18 | [binary_path, profile_path] 19 | end 20 | 21 | def download_file(url, path) 22 | Chromate::CLogger.log("Downloading #{url} to #{path}") 23 | Chromate::Binary.run('curl', ['-L', url, '-o', path]) 24 | 25 | path 26 | end 27 | 28 | def extension(platform) 29 | case platform 30 | when :mac 31 | 'dmg' 32 | when :linux 33 | 'deb' 34 | when :windows 35 | '7z' 36 | else 37 | raise 'Unsupported platform' 38 | end 39 | end 40 | 41 | def versions 42 | { 43 | v132: { 44 | mac: 'https://github.com/MiddleSchoolStudent/BotBrowser/releases/download/20250204/botbrowser_132.0.6834.84_mac_arm64.dmg', 45 | linux: 'https://github.com/MiddleSchoolStudent/BotBrowser/releases/download/20250204/botbrowser_132.0.6834.84_amd64.deb', 46 | windows: 'https://github.com/MiddleSchoolStudent/BotBrowser/releases/download/20250204/botbrowser_132.0.6834.84_win_x86_64.7z' 47 | }, 48 | v130: { 49 | mac: 'https://github.com/MiddleSchoolStudent/BotBrowser/releases/download/v130/botbrowser_130.0.6723.92_mac_arm64.dmg', 50 | linux: 'https://github.com/MiddleSchoolStudent/BotBrowser/releases/download/v130/botbrowser_130.0.6723.117_amd64.deb', 51 | windows: 'https://github.com/MiddleSchoolStudent/BotBrowser/releases/download/v130/botbrowser_130.0.6723.117_win_x86_64.7z' 52 | } 53 | } 54 | end 55 | 56 | def profiles 57 | { 58 | v128: { 59 | mac: 'https://raw.githubusercontent.com/MiddleSchoolStudent/BotBrowser/refs/heads/main/profiles/v128/chrome128_mac_arm64.enc', 60 | win: 'https://raw.githubusercontent.com/MiddleSchoolStudent/BotBrowser/refs/heads/main/profiles/v128/chrome128_win10_x86_64.enc' 61 | }, 62 | v129: { 63 | mac: 'https://raw.githubusercontent.com/MiddleSchoolStudent/BotBrowser/refs/heads/main/profiles/v129/chrome129_mac_arm64.enc' 64 | }, 65 | v130: { 66 | mac: 'https://raw.githubusercontent.com/MiddleSchoolStudent/BotBrowser/refs/heads/main/profiles/v130/chrome130_mac_arm64.enc', 67 | iphone: 'https://raw.githubusercontent.com/MiddleSchoolStudent/BotBrowser/refs/heads/main/profiles/v130/chrome130_iphone.enc' 68 | }, 69 | v132: { 70 | mac: 'https://github.com/MiddleSchoolStudent/BotBrowser/blob/main/profiles/v132/chrome132_mac_arm64.enc', 71 | win: 'https://github.com/MiddleSchoolStudent/BotBrowser/blob/main/profiles/v132/chrome132_win10_x86_64.enc' 72 | } 73 | } 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/bot_browser/installer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | require 'chromate/helpers' 5 | require 'chromate/c_logger' 6 | require 'bot_browser/downloader' 7 | 8 | module BotBrowser 9 | class Installer 10 | class NotInstalledError < StandardError; end 11 | class << self 12 | include Chromate::Helpers 13 | 14 | def install(version = nil) 15 | create_config_dir 16 | binary_path, profile_path = Downloader.download(version, nil, platform) 17 | bot_browser_path = install_binary(binary_path) 18 | bot_browser_profile_path = install_profile(profile_path) 19 | 20 | write_config(bot_browser_path, bot_browser_profile_path) 21 | end 22 | 23 | def config_dir 24 | "#{Dir.home}/.botbrowser" 25 | end 26 | 27 | def installed? 28 | File.exist?("#{config_dir}/config.yml") 29 | end 30 | 31 | def uninstall 32 | raise NotInstalledError, 'BotBrowser is not installed' unless installed? 33 | 34 | config = YAML.load_file("#{config_dir}/config.yml") 35 | Chromate::CLogger.log("Uninstalling binary at #{config["bot_browser_path"]}") 36 | FileUtils.rm_rf(config['bot_browser_path']) 37 | Chromate::CLogger.log("Uninstalling profile at #{config["profile"]}") 38 | FileUtils.rm_rf(config['profile']) 39 | FileUtils.rm_rf(config_dir) 40 | Chromate::CLogger.log('Uninstalled') 41 | end 42 | 43 | private 44 | 45 | def platform 46 | if mac? 47 | :mac 48 | elsif linux? 49 | :linux 50 | elsif windows? 51 | :windows 52 | else 53 | raise 'Unsupported platform' 54 | end 55 | end 56 | 57 | def install_binary(binary_path) 58 | Chromate::CLogger.log("Installing binary from #{binary_path}") 59 | return install_binary_mac(binary_path) if mac? 60 | return install_binary_linux(binary_path) if linux? 61 | return install_binary_windows(binary_path) if windows? 62 | 63 | raise 'Unsupported platform' 64 | end 65 | 66 | def create_config_dir 67 | Chromate::CLogger.log("Creating config directory at #{config_dir}") 68 | FileUtils.mkdir_p(config_dir) 69 | end 70 | 71 | def install_profile(profile_path) 72 | Chromate::CLogger.log("Installing profile from #{profile_path}") 73 | `cp #{profile_path} #{config_dir}/` 74 | 75 | "#{config_dir}/#{File.basename(profile_path)}" 76 | end 77 | 78 | def install_binary_mac(binary_path) 79 | Chromate::Binary.run('hdiutil', ['attach', binary_path]) 80 | Chromate::Binary.run('cp', ['-r', '/Volumes/Chromium/Chromium.app', '/Applications/']) 81 | Chromate::Binary.run('hdiutil', ['detach', '/Volumes/Chromium']) 82 | Chromate::Binary.run('xattr', ['-rd', 'com.apple.quarantine', '/Applications/Chromium.app']) 83 | Chromate::Binary.run('codesign', ['--force', '--deep', '--sign', '-', '/Applications/Chromium.app'], need_success: false) 84 | 85 | '/Applications/Chromium.app/Contents/MacOS/Chromium' 86 | end 87 | 88 | def install_binary_linux(binary_path) 89 | Chromate::Binary.run('dpkg', ['-i', binary_path]) 90 | Chromate::Binary.run('apt-get', ['install', '-f']) 91 | 92 | '/usr/bin/chromium-browser' 93 | end 94 | 95 | def install_binary_windows(binary_path) 96 | Chromate::Binary.run('7z', ['x', binary_path]) 97 | 98 | 'chromium.exe' 99 | end 100 | 101 | def write_config(bot_browser_path, bot_browser_profile_path) 102 | Chromate::CLogger.log("Writing config to #{config_dir}/config.yml") 103 | File.write(File.expand_path("#{config_dir}/config.yml"), <<~YAML) 104 | --- 105 | bot_browser_path: #{bot_browser_path} 106 | profile: #{bot_browser_profile_path} 107 | YAML 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/chromate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'chromate/version' 4 | require_relative 'chromate/browser' 5 | require_relative 'chromate/configuration' 6 | 7 | module Chromate 8 | class << self 9 | # @yield [Chromate::Configuration] 10 | def configure 11 | yield configuration 12 | end 13 | 14 | # @return [Chromate::Configuration] 15 | def configuration 16 | @configuration ||= Configuration.new 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/chromate/actions/dom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Actions 5 | module Dom 6 | # @return [String] 7 | def source 8 | evaluate_script('document.documentElement.outerHTML') 9 | end 10 | 11 | # @param selector [String] CSS selector 12 | # @return [Chromate::Element] 13 | def find_element(selector) 14 | base_element = Chromate::Element.new(selector, @client) 15 | 16 | options = { 17 | object_id: base_element.object_id, 18 | node_id: base_element.node_id, 19 | root_id: base_element.root_id 20 | } 21 | 22 | if base_element.select? 23 | Chromate::Elements::Select.new(selector, @client, **options) 24 | elsif base_element.option? 25 | Chromate::Elements::Option.new(selector, @client, **options) 26 | elsif base_element.radio? 27 | Chromate::Elements::Radio.new(selector, @client, **options) 28 | elsif base_element.checkbox? 29 | Chromate::Elements::Checkbox.new(selector, @client, **options) 30 | else 31 | base_element 32 | end 33 | end 34 | 35 | # @param selector [String] CSS selector 36 | # @return [String] 37 | def evaluate_script(script) 38 | result = @client.send_message('Runtime.evaluate', expression: script, returnByValue: true) 39 | 40 | result['result']['value'] 41 | rescue StandardError => e 42 | Chromate::CLogger.log("Error evaluating script: #{e.message}", level: :error) 43 | nil 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/chromate/actions/navigate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Actions 5 | module Navigate 6 | # @param [String] url 7 | # @return [self] 8 | def navigate_to(url) 9 | @client.send_message('Page.enable') 10 | @client.send_message('Page.navigate', url: url) 11 | wait_for_page_load 12 | end 13 | 14 | # @return [self] 15 | def wait_for_page_load # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength 16 | page_loaded = false 17 | dom_content_loaded = false 18 | frame_stopped_loading = false 19 | 20 | # Use Mutex for synchronization 21 | mutex = Mutex.new 22 | condition = ConditionVariable.new 23 | 24 | # Subscribe to websocket messages 25 | listener = proc do |message| 26 | mutex.synchronize do 27 | case message['method'] 28 | when 'Page.domContentEventFired' 29 | dom_content_loaded = true 30 | Chromate::CLogger.log('DOMContentEventFired') 31 | condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading 32 | when 'Page.loadEventFired' 33 | page_loaded = true 34 | Chromate::CLogger.log('LoadEventFired') 35 | condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading 36 | when 'Page.frameStoppedLoading' 37 | frame_stopped_loading = true 38 | Chromate::CLogger.log('FrameStoppedLoading') 39 | condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading 40 | end 41 | end 42 | end 43 | 44 | @client.on_message(&listener) 45 | 46 | # Wait for all three events (DOMContent, Load and FrameStoppedLoading) with a timeout 47 | Timeout.timeout(15) do 48 | mutex.synchronize do 49 | condition.wait(mutex) until dom_content_loaded && page_loaded && frame_stopped_loading 50 | end 51 | end 52 | 53 | @client.on_message { |msg| } # Remove listener 54 | 55 | self 56 | end 57 | 58 | # @return [self] 59 | def refresh 60 | @client.send_message('Page.reload') 61 | wait_for_page_load 62 | self 63 | end 64 | 65 | # @return [self] 66 | def go_back 67 | @client.send_message('Page.goBack') 68 | wait_for_page_load 69 | self 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/chromate/actions/screenshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Actions 5 | module Screenshot 6 | # @param file_path [String] The path to save the screenshot to 7 | # @param options [Hash] Options for the screenshot 8 | # @option options [String] :format The format of the screenshot (default: 'png') 9 | # @option options [Boolean] :full_page Whether to take a screenshot of the full page 10 | # @option options [Boolean] :fromSurface Whether to take a screenshot from the surface 11 | # @return [Hash] A hash containing the path and base64-encoded image data of the screenshot 12 | def screenshot(file_path = "#{Time.now.to_i}.png", options = {}) 13 | file_path ||= "#{Time.now.to_i}.png" 14 | return xvfb_screenshot(file_path) if @xfvb 15 | 16 | if options[:full_page] 17 | original_viewport = fetch_viewport_size 18 | update_screen_size_to_full_page! 19 | end 20 | 21 | image_data = make_screenshot(options) 22 | reset_screen_size! if options[:full_page] 23 | 24 | File.binwrite(file_path, image_data) 25 | 26 | { 27 | path: file_path, 28 | base64: Base64.encode64(image_data) 29 | } 30 | ensure 31 | restore_viewport_size(original_viewport) if options[:full_page] 32 | end 33 | 34 | private 35 | 36 | # @param file_path [String] The path to save the screenshot to 37 | # @return [Boolean] Whether the screenshot was successful 38 | def xvfb_screenshot(file_path) 39 | display = ENV['DISPLAY'] || ':99' 40 | system("xwd -root -display #{display} | convert xwd:- #{file_path}") 41 | end 42 | 43 | # Updates the screen size to match the full page dimensions 44 | # @return [void] 45 | def update_screen_size_to_full_page! 46 | metrics = @client.send_message('Page.getLayoutMetrics') 47 | content_size = metrics['contentSize'] 48 | width = content_size['width'].ceil 49 | height = content_size['height'].ceil 50 | 51 | @client.send_message('Emulation.setDeviceMetricsOverride', { 52 | mobile: false, 53 | width: width, 54 | height: height, 55 | deviceScaleFactor: 1 56 | }) 57 | end 58 | 59 | # Resets the device metrics override 60 | # @return [void] 61 | def reset_screen_size! 62 | @client.send_message('Emulation.clearDeviceMetricsOverride') 63 | end 64 | 65 | # Fetches the current viewport size 66 | # @return [Hash] The current viewport dimensions 67 | def fetch_viewport_size 68 | metrics = @client.send_message('Page.getLayoutMetrics') 69 | { 70 | width: metrics['layoutViewport']['clientWidth'], 71 | height: metrics['layoutViewport']['clientHeight'] 72 | } 73 | end 74 | 75 | # Restores the viewport size to its original dimensions 76 | # @param viewport [Hash] The original viewport dimensions 77 | # @return [void] 78 | def restore_viewport_size(viewport) 79 | return unless viewport 80 | 81 | @client.send_message('Emulation.setDeviceMetricsOverride', { 82 | mobile: false, 83 | width: viewport[:width], 84 | height: viewport[:height], 85 | deviceScaleFactor: 1 86 | }) 87 | end 88 | 89 | # @param options [Hash] Options for the screenshot 90 | # @option options [String] :format The format of the screenshot 91 | # @option options [Boolean] :fromSurface Whether to take a screenshot from the surface 92 | # @return [String] The image data 93 | def make_screenshot(options = {}) 94 | default_options = { 95 | format: 'png', 96 | fromSurface: true, 97 | captureBeyondViewport: true 98 | } 99 | 100 | params = default_options.merge(options) 101 | 102 | @client.send_message('Page.enable') 103 | @client.send_message('DOM.enable') 104 | @client.send_message('DOM.getDocument', depth: -1, pierce: true) 105 | 106 | result = @client.send_message('Page.captureScreenshot', params) 107 | 108 | image_data = result['data'] 109 | Base64.decode64(image_data) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/chromate/actions/stealth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'user_agent_parser' 4 | 5 | module Chromate 6 | module Actions 7 | module Stealth 8 | # @return [void] 9 | def patch 10 | @client.send_message('Network.enable') 11 | inject_stealth_script 12 | 13 | # TODO: Improve dynamic user agent overriding 14 | # It currently breaks fingerprint validation (pixcelscan.com) 15 | # override_user_agent(@user_agent) 16 | end 17 | 18 | # @return [void] 19 | def inject_stealth_script 20 | stealth_script = File.read(File.join(__dir__, '../files/stealth.js')) 21 | @client.send_message('Page.addScriptToEvaluateOnNewDocument', { source: stealth_script }) 22 | end 23 | 24 | # @param user_agent [String] 25 | # @return [void] 26 | def override_user_agent(user_agent) # rubocop:disable Metrics/MethodLength 27 | u_agent = UserAgentParser.parse(user_agent) 28 | platform = Chromate::UserAgent.os 29 | version = u_agent.version 30 | brands = [ 31 | { brand: u_agent.family || 'Not_A_Brand', version: version.major }, 32 | { brand: u_agent.device.brand || 'Not_A_Brand', version: u_agent.os.version.to_s } 33 | ] 34 | 35 | custom_headers = { 36 | 'User-Agent' => user_agent, 37 | 'Accept-Language' => 'en-US,en;q=0.9', 38 | 'Sec-CH-UA' => brands.map { |brand| "\"#{brand[:brand]}\";v=\"#{brand[:version]}\"" }.join(', '), 39 | 'Sec-CH-UA-Platform' => "\"#{u_agent.device.family}\"", 40 | 'Sec-CH-UA-Mobile' => '?0' 41 | } 42 | @client.send_message('Network.setExtraHTTPHeaders', headers: custom_headers) 43 | 44 | user_agent_override = { 45 | userAgent: user_agent, 46 | platform: platform, 47 | acceptLanguage: 'en-US,en;q=0.9', 48 | userAgentMetadata: { 49 | brands: brands, 50 | fullVersion: version.to_s, 51 | platform: platform, 52 | platformVersion: u_agent.os.version.to_s, 53 | architecture: Chromate::UserAgent.arch, 54 | model: '', 55 | mobile: false 56 | } 57 | } 58 | @client.send_message('Network.setUserAgentOverride', user_agent_override) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/chromate/binary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open3' 4 | 5 | module Chromate 6 | class Binary 7 | def self.run(path, args, need_success: true) 8 | command = [path] + args 9 | stdout, stderr, status = Open3.capture3(*command) 10 | raise stderr if need_success && !status.success? 11 | 12 | stdout 13 | end 14 | 15 | attr_reader :pid 16 | 17 | # @param [String] path 18 | # @param [Array] args 19 | def initialize(path, args) 20 | @path = path 21 | @args = args || [] 22 | @pid = nil 23 | end 24 | 25 | # @return [self] 26 | def start 27 | command = [@path] + @args 28 | _stdin, _stdout, _stderr, wait_thr = Open3.popen3(*command) 29 | CLogger.log("Started process with pid #{wait_thr.pid}", level: :debug) 30 | Process.detach(wait_thr.pid) 31 | CLogger.log("Process detached with pid #{wait_thr.pid}", level: :debug) 32 | @pid = wait_thr.pid 33 | 34 | self 35 | end 36 | 37 | # @return [Boolean] 38 | def started? 39 | !@pid.nil? 40 | end 41 | 42 | def running? 43 | return false unless started? 44 | 45 | Process.getpgid(@pid).is_a?(Integer) 46 | rescue Errno::ESRCH 47 | false 48 | end 49 | 50 | # @return [self] 51 | def stop 52 | stop_process 53 | end 54 | 55 | # @return [Boolean] 56 | def stopped? 57 | @pid.nil? 58 | end 59 | 60 | private 61 | 62 | def stop_process(timeout: 5) 63 | return unless pid 64 | 65 | # Send SIGINT to the process to stop it gracefully 66 | Process.kill('INT', pid) 67 | begin 68 | Timeout.timeout(timeout) do 69 | Process.wait(pid) 70 | end 71 | rescue Timeout::Error 72 | # If the process does not stop gracefully, send SIGKILL 73 | CLogger.log("Process #{pid} did not stop gracefully. Sending SIGKILL...", level: :debug) 74 | Process.kill('KILL', pid) 75 | Process.wait(pid) 76 | end 77 | rescue Errno::ESRCH 78 | # The process has already stopped 79 | ensure 80 | @pid = nil 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/chromate/browser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'base64' 4 | require 'json' 5 | require 'securerandom' 6 | require 'net/http' 7 | require 'websocket-client-simple' 8 | require_relative 'helpers' 9 | require_relative 'binary' 10 | require_relative 'client' 11 | require_relative 'hardwares' 12 | require_relative 'element' 13 | require_relative 'elements/select' 14 | require_relative 'elements/option' 15 | require_relative 'elements/tags' 16 | require_relative 'elements/radio' 17 | require_relative 'elements/checkbox' 18 | require_relative 'user_agent' 19 | require_relative 'actions/navigate' 20 | require_relative 'actions/screenshot' 21 | require_relative 'actions/dom' 22 | require_relative 'actions/stealth' 23 | 24 | module Chromate 25 | class Browser 26 | attr_reader :client, :options 27 | 28 | include Helpers 29 | include Actions::Navigate 30 | include Actions::Screenshot 31 | include Actions::Dom 32 | include Actions::Stealth 33 | 34 | # @param options [Hash] Options for the browser 35 | # @option options [String] :chrome_path The path to the Chrome executable 36 | # @option options [String] :user_data_dir The path to the user data directory 37 | # @option options [Boolean] :headless Whether to run Chrome in headless mode 38 | # @option options [Boolean] :xfvb Whether to run Chrome in Xvfb 39 | # @option options [Boolean] :native_control Whether to use native controls 40 | # @option options [Boolean] :record Whether to record the screen 41 | def initialize(options = {}) 42 | @options = config.options.merge(options) 43 | @chrome_path = @options.fetch(:chrome_path) 44 | @user_data_dir = @options.fetch(:user_data_dir, "/tmp/chromate_#{SecureRandom.hex}") 45 | @headless = @options.fetch(:headless) 46 | @xfvb = @options.fetch(:xfvb) 47 | @native_control = @options.fetch(:native_control) 48 | @record = @options.fetch(:record, false) 49 | @binary = nil 50 | @record_process = nil 51 | @client = nil 52 | @args = [] 53 | 54 | trap('INT') { stop_and_exit } 55 | trap('TERM') { stop_and_exit } 56 | end 57 | 58 | # @return [self] 59 | def start 60 | build_args 61 | @client = Client.new(self) 62 | @args << "--remote-debugging-port=#{@client.port}" 63 | 64 | if @xfvb 65 | if ENV['DISPLAY'].nil? 66 | ENV['DISPLAY'] = ':0' if mac? # XQuartz generally uses :0 on Mac 67 | ENV['DISPLAY'] = ':99' if linux? # Xvfb generally uses :99 on Linux 68 | end 69 | @args << "--display=#{ENV.fetch("DISPLAY", nil)}" 70 | end 71 | 72 | Hardwares::MouseController.reset_mouse_position 73 | Chromate::CLogger.log("Starting browser with args: #{@args}", level: :debug) 74 | @binary = Binary.new(@chrome_path, @args) 75 | 76 | @binary.start 77 | @client.start 78 | 79 | start_video_recording if @record 80 | 81 | patch if config.patch? 82 | 83 | update_config! 84 | 85 | self 86 | end 87 | 88 | # @return [Boolean] 89 | def started? 90 | @binary&.started? || false 91 | end 92 | 93 | # @return [self] 94 | def stop 95 | stop_process(@record_process) if @record_process 96 | @binary.stop if started? 97 | @client&.stop 98 | 99 | @binary = nil 100 | @record_process = nil 101 | 102 | self 103 | end 104 | 105 | # @return [Boolean] 106 | def native_control? 107 | @native_control 108 | end 109 | 110 | private 111 | 112 | # @return [Integer] 113 | def start_video_recording 114 | outname = @record.is_a?(String) ? @record : "output_video_#{Time.now.to_i}.mp4" 115 | outfile = File.join(Dir.pwd, outname).to_s 116 | args = [ 117 | '-f', 118 | 'x11grab', 119 | '-draw_mouse', 120 | '1', 121 | '-r', 122 | '30', 123 | '-s', 124 | '1920x1080', 125 | '-i', 126 | ENV.fetch('DISPLAY'), 127 | '-c:v', 128 | 'libx264', 129 | '-preset', 130 | 'ultrafast', 131 | '-pix_fmt', 132 | 'yuv420p', 133 | '-y', 134 | outfile 135 | ] 136 | binary = Binary.new('ffmpeg', args) 137 | binary.start 138 | @record_process = binary.pid 139 | end 140 | 141 | # @return [Array] 142 | def build_args 143 | exclude_switches = config.exclude_switches || [] 144 | exclude_switches += @options[:exclude_switches] if @options[:exclude_switches] 145 | @user_agent = @options[:user_agent] || UserAgent.call 146 | 147 | @args = if @options.dig(:options, :args) 148 | @options[:options][:args] 149 | else 150 | config.generate_arguments(**@options) 151 | end 152 | 153 | @args << "--user-agent=#{@user_agent}" 154 | @args << "--exclude-switches=#{exclude_switches.join(",")}" if exclude_switches.any? 155 | @args << "--user-data-dir=#{@user_data_dir}" 156 | 157 | @args 158 | end 159 | 160 | def set_hardwares 161 | config.mouse_controller = Hardwares.mouse(client: @client, element: nil) 162 | config.keyboard_controller = Hardwares.keyboard(client: @client, element: nil) 163 | end 164 | 165 | # @return [void] 166 | def update_config! 167 | config.args = @args 168 | config.user_data_dir = @user_data_dir 169 | config.headless = @headless 170 | config.xfvb = @xfvb 171 | config.native_control = @native_control 172 | 173 | set_hardwares 174 | end 175 | 176 | # @param pid [Integer] PID of the process to stop 177 | # @param timeout [Integer] Timeout in seconds to wait for the process to stop 178 | # @return [void] 179 | def stop_process(pid, timeout: 5) 180 | return unless pid 181 | 182 | # Send SIGINT to the process to stop it gracefully 183 | Process.kill('INT', pid) 184 | begin 185 | Timeout.timeout(timeout) do 186 | Process.wait(pid) 187 | end 188 | rescue Timeout::Error 189 | # If the process does not stop gracefully, send SIGKILL 190 | CLogger.log("Process #{pid} did not stop gracefully. Sending SIGKILL...", level: :debug) 191 | Process.kill('KILL', pid) 192 | end 193 | rescue Errno::ESRCH 194 | # The process has already stopped 195 | end 196 | 197 | # @return [void] 198 | def stop_and_exit 199 | CLogger.log('Stopping browser...', level: :debug) 200 | stop 201 | exit 202 | end 203 | 204 | # @return [Chromate::Configuration] 205 | def config 206 | Chromate.configuration 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/chromate/c_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | 5 | module Chromate 6 | class CLogger < Logger 7 | # @param [IO] logdev 8 | # @param [Integer] shift_age 9 | # @param [Integer] shift_size 10 | def initialize(logdev, shift_age: 0, shift_size: 1_048_576) 11 | super(logdev, shift_age, shift_size) 12 | self.formatter = proc do |severity, datetime, _progname, msg| 13 | "[Chromate] #{datetime.strftime("%Y-%m-%d %H:%M:%S")} #{severity}: #{msg}\n" 14 | end 15 | self.level = ENV['CHROMATE_DEBUG'] ? :debug : :info 16 | end 17 | 18 | # @return [Chromate::CLogger] 19 | def self.logger 20 | @logger ||= new($stdout) 21 | end 22 | 23 | # @param [String] message 24 | # @param [Symbol] level 25 | # @return [void] 26 | def self.log(message, level: :info) 27 | logger.send(level, message) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/chromate/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'websocket-client-simple' 4 | require 'chromate/helpers' 5 | require 'chromate/exceptions' 6 | 7 | module Chromate 8 | class Client 9 | include Helpers 10 | 11 | # @return [Array] 12 | def self.listeners 13 | @@listeners ||= [] # rubocop:disable Style/ClassVars 14 | end 15 | 16 | attr_reader :port, :ws, :browser 17 | 18 | # @param [Chromate::Browser] browser 19 | def initialize(browser) 20 | @browser = browser 21 | options = browser.options 22 | @port = options[:port] || find_available_port 23 | end 24 | 25 | # @return [self] 26 | def start 27 | @ws_url = fetch_websocket_debug_url 28 | @ws = WebSocket::Client::Simple.connect(@ws_url) 29 | @id = 0 30 | @callbacks = {} 31 | 32 | client_self = self 33 | 34 | @ws.on :message do |msg| 35 | message = JSON.parse(msg.data) 36 | client_self.send(:handle_message, message) 37 | 38 | Client.listeners.each do |listener| 39 | listener.call(message) 40 | end 41 | end 42 | 43 | @ws.on :open do 44 | Chromate::CLogger.log('Successfully connected to WebSocket', level: :debug) 45 | end 46 | 47 | @ws.on :error do |e| 48 | Chromate::CLogger.log("WebSocket error: #{e.message}", level: :error) 49 | end 50 | 51 | @ws.on :close do |_e| 52 | Chromate::CLogger.log('WebSocket connection closed', level: :debug) 53 | end 54 | 55 | sleep 0.2 # Wait for the connection to be established 56 | client_self.send_message('Target.setDiscoverTargets', { discover: true }) 57 | 58 | client_self 59 | end 60 | 61 | # @return [self] 62 | def stop 63 | @ws&.close 64 | 65 | self 66 | end 67 | 68 | # @param [String] method 69 | # @param [Hash] params 70 | # @return [Hash] 71 | def send_message(method, params = {}) 72 | @id += 1 73 | message = { id: @id, method: method, params: params } 74 | Chromate::CLogger.log("Sending WebSocket message: #{message}", level: :debug) 75 | 76 | begin 77 | @ws.send(message.to_json) 78 | @callbacks[@id] = Queue.new 79 | result = @callbacks[@id].pop 80 | Chromate::CLogger.log("Response received for message #{message[:id]}: #{result}", level: :debug) 81 | result 82 | rescue StandardError => e 83 | Chromate::CLogger.log("Error sending WebSocket message: #{e.message}", level: :error) 84 | reconnect 85 | retry 86 | end 87 | end 88 | 89 | # @return [self] 90 | def reconnect 91 | @ws_url = fetch_websocket_debug_url 92 | @ws = WebSocket::Client::Simple.connect(@ws_url) 93 | Chromate::CLogger.log('Successfully reconnected to WebSocket') 94 | 95 | self 96 | end 97 | 98 | # Allowing different parts to subscribe to WebSocket messages 99 | # @yieldparam [Hash] message 100 | # @return [void] 101 | def on_message(&block) 102 | Client.listeners << block 103 | end 104 | 105 | private 106 | 107 | # @param [Hash] message 108 | # @return [self] 109 | def handle_message(message) 110 | Chromate::CLogger.log("Message received: #{message}", level: :debug) 111 | return unless message['id'] && @callbacks[message['id']] 112 | 113 | @callbacks[message['id']].push(message['result']) 114 | @callbacks.delete(message['id']) 115 | 116 | self 117 | end 118 | 119 | # @return [String] 120 | def fetch_websocket_debug_url 121 | retries = 0 122 | max_retries = 5 123 | base_delay = 0.5 124 | 125 | begin 126 | uri = URI("http://localhost:#{@port}/json/list") 127 | response = Net::HTTP.get(uri) 128 | targets = JSON.parse(response) 129 | 130 | page_target = targets.find { |target| target['type'] == 'page' } 131 | websocket_url = if page_target 132 | page_target['webSocketDebuggerUrl'] 133 | else 134 | create_new_page_target 135 | end 136 | raise Exceptions::DebugURLError, 'Can\'t get WebSocket URL' if websocket_url.nil? 137 | 138 | websocket_url 139 | rescue StandardError => e 140 | retries += 1 141 | raise Exceptions::ConnectionTimeoutError, "Can't get WebSocket URL after #{max_retries} retries" if retries >= max_retries 142 | 143 | delay = base_delay * (2**retries) # Exponential delay: 0.5s, 1s, 2s, 4s, 8s 144 | Chromate::CLogger.log("Attempting to reconnect in #{delay} seconds, #{e.message}", level: :debug) 145 | sleep delay 146 | retry 147 | end 148 | end 149 | 150 | # @return [String] 151 | def create_new_page_target 152 | uri = URI("http://localhost:#{@port}/json/new") 153 | response = Net::HTTP.get(uri) 154 | new_target = JSON.parse(response) 155 | 156 | new_target['webSocketDebuggerUrl'] 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/chromate/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'helpers' 4 | require_relative 'exceptions' 5 | require_relative 'c_logger' 6 | 7 | module Chromate 8 | class Configuration 9 | include Helpers 10 | include Exceptions 11 | DEFAULT_ARGS = [ 12 | '--no-first-run', # Skip the first run wizard 13 | '--no-default-browser-check', # Disable the default browser check 14 | '--disable-blink-features=AutomationControlled', # Disable the AutomationControlled feature 15 | '--disable-extensions', # Disable extensions 16 | '--disable-infobars', # Disable the infobar that asks if you want to install Chrome 17 | '--no-sandbox', # Required for chrome devtools to work 18 | '--test-type', # Remove the not allowed message for --no-sandbox flag 19 | '--disable-dev-shm-usage', # Disable /dev/shm usage 20 | '--disable-popup-blocking', # Disable popup blocking 21 | '--ignore-certificate-errors', # Ignore certificate errors 22 | '--window-size=1920,1080', # TODO: Make this automatic 23 | '--hide-crash-restore-bubble' # Hide the crash restore bubble 24 | ].freeze 25 | HEADLESS_ARGS = [ 26 | '--headless=new', 27 | '--window-position=2400,2400' 28 | ].freeze 29 | XVFB_ARGS = [ 30 | '--window-position=0,0', 31 | '--start-fullscreen' 32 | ].freeze 33 | DISABLED_FEATURES = %w[ 34 | Translate 35 | OptimizationHints 36 | MediaRouter 37 | DialMediaRouteProvider 38 | CalculateNativeWinOcclusion 39 | InterestFeedContentSuggestions 40 | CertificateTransparencyComponentUpdater 41 | AutofillServerCommunication 42 | PrivacySandboxSettings4 43 | AutomationControlled 44 | ].freeze 45 | EXCLUDE_SWITCHES = %w[ 46 | enable-automation 47 | ].freeze 48 | 49 | attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :startup_patch, 50 | :args, :headless_args, :xfvb_args, :exclude_switches, :proxy, :disable_features, 51 | :mouse_controller, :keyboard_controller 52 | 53 | def initialize 54 | @user_data_dir = File.expand_path('~/.config/google-chrome/Default') 55 | @headless = true 56 | @xfvb = false 57 | @native_control = false 58 | @startup_patch = true 59 | @proxy = nil 60 | @args = [] + DEFAULT_ARGS 61 | @headless_args = [] + HEADLESS_ARGS 62 | @xfvb_args = [] + XVFB_ARGS 63 | @disable_features = [] + DISABLED_FEATURES 64 | @exclude_switches = [] + EXCLUDE_SWITCHES 65 | 66 | @args << '--use-angle=metal' if mac? 67 | end 68 | 69 | # @return [Chromate::Configuration] 70 | def self.config 71 | @config ||= Configuration.new 72 | end 73 | 74 | # @yield [Chromate::Configuration] 75 | def self.configure 76 | yield(config) 77 | end 78 | 79 | # @return [Chromate::Configuration] 80 | def config 81 | self.class.config 82 | end 83 | 84 | # @return [Boolean] 85 | def patch? 86 | @startup_patch 87 | end 88 | 89 | # @return [String] 90 | def chrome_path 91 | return ENV['CHROME_BIN'] if ENV['CHROME_BIN'] 92 | 93 | if mac? 94 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' 95 | elsif linux? 96 | '/usr/bin/google-chrome-stable' 97 | elsif windows? 98 | 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe' 99 | else 100 | raise Exceptions::InvalidPlatformError, 'Unsupported platform' 101 | end 102 | end 103 | 104 | # @option [Boolean] headless 105 | # @option [Boolean] xfvb 106 | # @option [Hash] proxy 107 | # @option [Array] disable_features 108 | def generate_arguments(headless: @headless, xfvb: @xfvb, proxy: @proxy, disable_features: @disable_features, **_args) 109 | dynamic_args = [] 110 | 111 | dynamic_args += @headless_args if headless 112 | dynamic_args += @xfvb_args if xfvb 113 | dynamic_args << "--proxy-server=#{proxy[:host]}:#{proxy[:port]}" if proxy && proxy[:host] && proxy[:port] 114 | dynamic_args << "--disable-features=#{disable_features.join(",")}" unless disable_features.empty? 115 | 116 | @args + dynamic_args 117 | end 118 | 119 | # @return [Hash] 120 | def options 121 | { 122 | chrome_path: chrome_path, 123 | user_data_dir: @user_data_dir, 124 | headless: @headless, 125 | xfvb: @xfvb, 126 | native_control: @native_control, 127 | args: @args, 128 | headless_args: @headless_args, 129 | xfvb_args: @xfvb_args, 130 | exclude_switches: @exclude_switches, 131 | proxy: @proxy, 132 | disable_features: @disable_features 133 | } 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/chromate/elements/checkbox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/element' 4 | 5 | module Chromate 6 | module Elements 7 | class Checkbox < Element 8 | def initialize(selector, client, **options) 9 | super 10 | raise InvalidSelectorError, selector unless checkbox? 11 | end 12 | 13 | # @return [Boolean] 14 | def checked? 15 | attributes['checked'] == 'true' 16 | end 17 | 18 | # @return [self] 19 | def check 20 | click unless checked? 21 | 22 | self 23 | end 24 | 25 | # @return [self] 26 | def uncheck 27 | click if checked? 28 | 29 | self 30 | end 31 | 32 | # @return [self] 33 | def toggle 34 | click 35 | 36 | self 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/chromate/elements/option.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/element' 4 | 5 | module Chromate 6 | module Elements 7 | class Option < Element 8 | attr_reader :value 9 | 10 | # @param [String] value 11 | def initialize(value, client, node_id: nil, object_id: nil, root_id: nil) 12 | super("option[value='#{value}']", client, node_id: node_id, object_id: object_id, root_id: root_id) 13 | 14 | @value = value 15 | end 16 | 17 | def bounding_box 18 | script = <<~JAVASCRIPT 19 | function() { 20 | const select = this.closest('select'); 21 | const rect = select.getBoundingClientRect(); 22 | return { 23 | x: rect.x, 24 | y: rect.y, 25 | width: rect.width, 26 | height: rect.height 27 | }; 28 | } 29 | JAVASCRIPT 30 | 31 | result = evaluate_script(script) 32 | # TODO: fix this 33 | # The offset is due to the fact that the option return the wrong coordinates 34 | # can be fixed by mesuring an option and use the offset multiply by the index of the option 35 | { 36 | 'content' => [result['x'] + 100, result['y'] + 100], 37 | 'width' => result['width'], 38 | 'height' => result['height'] 39 | } 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/chromate/elements/radio.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/element' 4 | 5 | module Chromate 6 | module Elements 7 | class Radio < Element 8 | def initialize(selector = nil, client = nil, **options) 9 | if selector 10 | super 11 | raise InvalidSelectorError, selector unless radio? 12 | else 13 | super(**options) 14 | end 15 | end 16 | 17 | # @return [Boolean] 18 | def checked? 19 | attributes['checked'] == 'true' 20 | end 21 | 22 | # @return [self] 23 | def check 24 | click unless checked? 25 | 26 | self 27 | end 28 | 29 | # @return [self] 30 | def uncheck 31 | click if checked? 32 | 33 | self 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/chromate/elements/select.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/element' 4 | require 'chromate/elements/option' 5 | 6 | module Chromate 7 | module Elements 8 | class Select < Element 9 | # @param [String] value 10 | # @return [self] 11 | def select_option(value) 12 | click 13 | 14 | evaluate_script(javascript, arguments: [{ value: value }]) unless Chromate.configuration.native_control 15 | 16 | Option.new(value, client).click 17 | 18 | self 19 | end 20 | 21 | # @return [String|nil] 22 | def selected_value 23 | evaluate_script('function() { return this.value; }') 24 | end 25 | 26 | # @return [String|nil] 27 | def selected_text 28 | evaluate_script('function() { 29 | const option = this.options[this.selectedIndex]; 30 | return option ? option.textContent.trim() : null; 31 | }') 32 | end 33 | 34 | private 35 | 36 | # @return [String] 37 | def javascript 38 | <<~JAVASCRIPT 39 | function() { 40 | this.focus(); 41 | this.dispatchEvent(new MouseEvent('mousedown')); 42 | 43 | const options = Array.from(this.options); 44 | const option = options.find(opt =>#{" "} 45 | opt.value === arguments[0] || opt.textContent.trim() === arguments[0] 46 | ); 47 | 48 | if (!option) { 49 | throw new Error(`Option '${arguments[0]}' not found in select`); 50 | } 51 | 52 | this.value = option.value; 53 | 54 | this.dispatchEvent(new Event('change', { bubbles: true })); 55 | this.dispatchEvent(new Event('input', { bubbles: true })); 56 | 57 | this.blur(); 58 | } 59 | JAVASCRIPT 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/chromate/elements/tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/element' 4 | 5 | module Chromate 6 | module Elements 7 | module Tags 8 | def select? 9 | tag_name == 'select' 10 | end 11 | 12 | def option? 13 | tag_name == 'option' 14 | end 15 | 16 | def radio? 17 | tag_name == 'input' && attributes['type'] == 'radio' 18 | end 19 | 20 | def checkbox? 21 | tag_name == 'input' && attributes['type'] == 'checkbox' 22 | end 23 | 24 | def base? 25 | !select? && !option? && !radio? && !checkbox? 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/chromate/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Exceptions 5 | class ChromateError < StandardError; end 6 | class InvalidBrowserError < ChromateError; end 7 | class InvalidPlatformError < ChromateError; end 8 | class ConnectionTimeoutError < StandardError; end 9 | class DebugURLError < StandardError; end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/chromate/files/agents.json: -------------------------------------------------------------------------------- 1 | { 2 | "windows": [ 3 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" 4 | ], 5 | "mac": [ 6 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" 7 | ], 8 | "linux": [ 9 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" 10 | ] 11 | } -------------------------------------------------------------------------------- /lib/chromate/hardwares.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/c_logger' 4 | require 'chromate/hardwares/keyboard_controller' 5 | require 'chromate/hardwares/mouse_controller' 6 | require 'chromate/hardwares/mouses/virtual_controller' 7 | require 'chromate/hardwares/keyboards/virtual_controller' 8 | require 'chromate/helpers' 9 | 10 | module Chromate 11 | module Hardwares 12 | extend Helpers 13 | 14 | # @param [Hash] args 15 | # @option args [Chromate::Client] :client 16 | # @option args [Chromate::Element] :element 17 | # @return [Chromate::Hardwares::MouseController] 18 | def mouse(**args) 19 | browser = args[:client].browser 20 | if browser.options[:native_control] 21 | if mac? 22 | Chromate::CLogger.log('👨🏼‍💻🐁 Loading MacOs mouse controller') 23 | require 'chromate/hardwares/mouses/mac_os_controller' 24 | return Mouses::MacOsController.new(**args) 25 | end 26 | if linux? 27 | Chromate::CLogger.log('👨🏼‍💻🐁 Loading Linux mouse controller') 28 | require 'chromate/hardwares/mouses/linux_controller' 29 | return Mouses::LinuxController.new(**args) 30 | end 31 | raise 'Native mouse controller is not supported on Windows' if windows? 32 | else 33 | Chromate::CLogger.log('🤖🐁 Loading Virtual mouse controller') 34 | Mouses::VirtualController.new(**args) 35 | end 36 | end 37 | module_function :mouse 38 | 39 | # @param [Hash] args 40 | # @option args [Chromate::Client] :client 41 | # @option args [Chromate::Element] :element 42 | # @return [Chromate::Hardwares::KeyboardController] 43 | def keyboard(**args) 44 | Chromate::CLogger.log('🤖⌨️ Loading Virtual keyboard controller') 45 | Keyboards::VirtualController.new(**args) 46 | end 47 | module_function :keyboard 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/keyboard_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Hardwares 5 | class KeyboardController 6 | attr_accessor :element, :client 7 | 8 | # @param [Chromate::Element] element 9 | # @param [Chromate::Client] client 10 | def initialize(element: nil, client: nil) 11 | @element = element 12 | @client = client 13 | @type_interval = rand(0.05..0.1) 14 | end 15 | 16 | # @param [Chromate::Element] element 17 | # @return [self] 18 | def set_element(element) # rubocop:disable Naming/AccessorMethodName 19 | @element = element 20 | @type_interval = rand(0.05..0.1) 21 | 22 | self 23 | end 24 | 25 | # @param [String] key 26 | # @return [self] 27 | def press_key(_key) 28 | raise NotImplementedError 29 | end 30 | 31 | # @param [String] text 32 | # @return [self] 33 | def type(text) 34 | text.each_char do |char| 35 | sleep(rand(0.01..0.05)) if rand(10).zero? 36 | 37 | press_key(char) 38 | sleep(@type_interval) 39 | end 40 | 41 | self 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/keyboards/virtual_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Hardwares 5 | module Keyboards 6 | class VirtualController < Chromate::Hardwares::KeyboardController 7 | def press_key(key = 'Enter') 8 | params = { 9 | key: key, 10 | code: key_to_code(key), 11 | windowsVirtualKeyCode: key_to_virtual_code(key) 12 | } 13 | 14 | params[:text] = key if key.length == 1 15 | 16 | # Dispatch keyDown event 17 | client.send_message('Input.dispatchKeyEvent', params.merge(type: 'keyDown')) 18 | 19 | # Dispatch keyUp event 20 | client.send_message('Input.dispatchKeyEvent', params.merge(type: 'keyUp')) 21 | 22 | self 23 | end 24 | 25 | private 26 | 27 | # @param [String] key 28 | # @return [String] 29 | def key_to_code(key) 30 | case key 31 | when 'Enter' then 'Enter' 32 | when 'Tab' then 'Tab' 33 | when 'Backspace' then 'Backspace' 34 | when 'Delete' then 'Delete' 35 | when 'Escape' then 'Escape' 36 | when 'ArrowLeft' then 'ArrowLeft' 37 | when 'ArrowRight' then 'ArrowRight' 38 | when 'ArrowUp' then 'ArrowUp' 39 | when 'ArrowDown' then 'ArrowDown' 40 | else 41 | "Key#{key.upcase}" 42 | end 43 | end 44 | 45 | # @param [String] key 46 | # @return [Integer] 47 | def key_to_virtual_code(key) 48 | case key 49 | when 'Enter' then 0x0D 50 | when 'Tab' then 0x09 51 | when 'Backspace' then 0x08 52 | when 'Delete' then 0x2E 53 | when 'Escape' then 0x1B 54 | when 'ArrowLeft' then 0x25 55 | when 'ArrowRight' then 0x27 56 | when 'ArrowUp' then 0x26 57 | when 'ArrowDown' then 0x28 58 | else 59 | key.upcase.ord 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/mouse_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Hardwares 5 | class MouseController 6 | CLICK_DURATION_RANGE = (0.01..0.1) 7 | DOUBLE_CLICK_DURATION_RANGE = (0.1..0.5) 8 | 9 | def self.reset_mouse_position 10 | @@mouse_position = { x: 0, y: 0 } # rubocop:disable Style/ClassVars 11 | end 12 | 13 | attr_accessor :element, :client 14 | 15 | # @param [Chromate::Element] element 16 | # @param [Chromate::Client] client 17 | def initialize(element: nil, client: nil) 18 | @element = element 19 | @client = client 20 | end 21 | 22 | # @param [Chromate::Element] element 23 | # @return [self] 24 | def set_element(element) # rubocop:disable Naming/AccessorMethodName 25 | @element = element 26 | 27 | self 28 | end 29 | 30 | # @return [Hash] 31 | def mouse_position 32 | @@mouse_position ||= { x: 0, y: 0 } # rubocop:disable Style/ClassVars 33 | end 34 | 35 | # @return [self] 36 | def hover 37 | raise NotImplementedError 38 | end 39 | 40 | # @return [self] 41 | def click 42 | raise NotImplementedError 43 | end 44 | 45 | # @return [self] 46 | def double_click 47 | raise NotImplementedError 48 | end 49 | 50 | # @return [self] 51 | def right_click 52 | raise NotImplementedError 53 | end 54 | 55 | # @params [Chromate::Element] element 56 | # @return [self] 57 | def drag_and_drop_to(element) 58 | raise NotImplementedError 59 | end 60 | 61 | # @return [Integer] 62 | def position_x 63 | mouse_position[:x] 64 | end 65 | 66 | # @return [Integer] 67 | def position_y 68 | mouse_position[:y] 69 | end 70 | 71 | private 72 | 73 | # @return [Integer] 74 | def target_x 75 | element.x + (element.width / 2) 76 | end 77 | 78 | # @return [Integer] 79 | def target_y 80 | element.y + (element.height / 2) 81 | end 82 | 83 | # @param [Integer] steps 84 | # @return [Array] 85 | def bezier_curve(steps:, start_x: position_x, start_y: position_y, t_x: target_x, t_y: target_y) # rubocop:disable Metrics/AbcSize 86 | # Points for the Bézier curve 87 | control_x1 = start_x + (rand(50..150) * (t_x > start_x ? 1 : -1)) 88 | control_y1 = start_y + (rand(50..150) * (t_y > start_y ? 1 : -1)) 89 | control_x2 = t_x + (rand(50..150) * (t_x > start_x ? -1 : 1)) 90 | control_y2 = t_y + (rand(50..150) * (t_y > start_y ? -1 : 1)) 91 | 92 | (0..steps).map do |i| 93 | t = i.to_f / steps 94 | x = (((1 - t)**3) * start_x) + (3 * ((1 - t)**2) * t * control_x1) + (3 * (1 - t) * (t**2) * control_x2) + ((t**3) * t_x) 95 | y = (((1 - t)**3) * start_y) + (3 * ((1 - t)**2) * t * control_y1) + (3 * (1 - t) * (t**2) * control_y2) + ((t**3) * t_y) 96 | { x: x, y: y } 97 | end 98 | end 99 | 100 | # @param [Integer] target_x 101 | # @param [Integer] target_y 102 | # @return [Hash] 103 | def update_mouse_position(target_x, target_y) 104 | @@mouse_position[:x] = target_x 105 | @@mouse_position[:y] = target_y 106 | 107 | mouse_position 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/mouses/linux_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate/helpers' 4 | require_relative 'x11' 5 | 6 | module Chromate 7 | module Hardwares 8 | module Mouses 9 | class LinuxController < MouseController 10 | class InvalidPlatformError < StandardError; end 11 | include Helpers 12 | 13 | LEFT_BUTTON = 1 14 | RIGHT_BUTTON = 3 15 | 16 | def initialize(element: nil, client: nil) 17 | raise InvalidPlatformError, 'MouseController is only supported on Linux' unless linux? 18 | 19 | super 20 | @display = X11.XOpenDisplay(nil) 21 | raise 'Impossible d\'ouvrir l\'affichage X11' if @display.null? 22 | 23 | @root_window = X11.XDefaultRootWindow(@display) 24 | end 25 | 26 | def hover 27 | focus_chrome_window 28 | smooth_move_to(target_x, target_y) 29 | update_mouse_position(target_x, target_y) 30 | end 31 | 32 | def click 33 | hover 34 | simulate_button_event(LEFT_BUTTON, true) 35 | sleep(rand(CLICK_DURATION_RANGE)) 36 | simulate_button_event(LEFT_BUTTON, false) 37 | end 38 | 39 | def right_click 40 | hover 41 | simulate_button_event(RIGHT_BUTTON, true) 42 | sleep(rand(CLICK_DURATION_RANGE)) 43 | simulate_button_event(RIGHT_BUTTON, false) 44 | end 45 | 46 | def double_click 47 | click 48 | sleep(rand(DOUBLE_CLICK_DURATION_RANGE)) 49 | click 50 | end 51 | 52 | def drag_and_drop_to(element) 53 | hover 54 | 55 | target_x = element.x + (element.width / 2) 56 | target_y = element.y + (element.height / 2) 57 | start_x = position_x 58 | start_y = position_y 59 | steps = rand(25..50) 60 | duration = rand(0.1..0.3) 61 | 62 | # Generate a Bézier curve for natural movement 63 | points = bezier_curve(steps: steps, start_x: start_x, start_y: start_y, t_x: target_x, t_y: target_y) 64 | 65 | # Step 1: Press the left mouse button 66 | simulate_button_event(LEFT_BUTTON, true) 67 | sleep(rand(CLICK_DURATION_RANGE)) 68 | 69 | # Step 2: Drag the element 70 | points.each do |point| 71 | move_mouse_to(point[:x], point[:y]) 72 | sleep(duration / steps) 73 | end 74 | 75 | # Step 3: Release the left mouse button 76 | simulate_button_event(LEFT_BUTTON, false) 77 | 78 | # Update the mouse position 79 | update_mouse_position(target_x, target_y) 80 | 81 | self 82 | end 83 | 84 | private 85 | 86 | def smooth_move_to(dest_x, dest_y) 87 | start_x = position_x 88 | start_y = position_y 89 | 90 | steps = rand(25..50) 91 | duration = rand(0.1..0.3) 92 | 93 | # Build a Bézier curve for natural movement 94 | points = bezier_curve(steps: steps, start_x: start_x, start_y: start_y, t_x: dest_x, t_y: dest_y) 95 | 96 | # Move the mouse along the Bézier curve 97 | points.each do |point| 98 | move_mouse_to(point[:x], point[:y]) 99 | sleep(duration / steps) 100 | end 101 | end 102 | 103 | def move_mouse_to(x_target, y_target) 104 | X11.XWarpPointer(@display, 0, @root_window, 0, 0, 0, 0, x_target.to_i, y_target.to_i) 105 | X11.XFlush(@display) 106 | end 107 | 108 | def focus_chrome_window 109 | chrome_window = find_window_by_name(@root_window, 'Chrome') 110 | if chrome_window.zero? 111 | Chromate::CLogger.log('No Chrome window found') 112 | else 113 | X11.XRaiseWindow(@display, chrome_window) 114 | X11.XSetInputFocus(@display, chrome_window, X11::REVERT_TO_PARENT, 0) 115 | X11.XFlush(@display) 116 | end 117 | end 118 | 119 | def find_window_by_name(window, name) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 120 | root_return = FFI::MemoryPointer.new(:ulong) 121 | parent_return = FFI::MemoryPointer.new(:ulong) 122 | children_return = FFI::MemoryPointer.new(:pointer) 123 | nchildren_return = FFI::MemoryPointer.new(:uint) 124 | 125 | status = X11.XQueryTree(@display, window, root_return, parent_return, children_return, nchildren_return) 126 | return 0 if status.zero? 127 | 128 | nchildren = nchildren_return.read_uint 129 | children_ptr = children_return.read_pointer 130 | 131 | return 0 if nchildren.zero? || children_ptr.null? 132 | 133 | children = children_ptr.get_array_of_ulong(0, nchildren) 134 | found_window = 0 135 | 136 | children.each do |child| 137 | window_name_ptr = FFI::MemoryPointer.new(:pointer) 138 | status = X11.XFetchName(@display, child, window_name_ptr) 139 | if status != 0 && !window_name_ptr.read_pointer.null? 140 | window_name = window_name_ptr.read_pointer.read_string 141 | if window_name.include?(name) 142 | X11.XFree(window_name_ptr.read_pointer) 143 | found_window = child 144 | break 145 | end 146 | X11.XFree(window_name_ptr.read_pointer) 147 | end 148 | # Recursive search for the window 149 | found_window = find_window_by_name(child, name) 150 | break if found_window != 0 151 | end 152 | 153 | X11.XFree(children_ptr) 154 | found_window 155 | end 156 | 157 | def current_mouse_position 158 | root_return = FFI::MemoryPointer.new(:ulong) 159 | child_return = FFI::MemoryPointer.new(:ulong) 160 | root_x = FFI::MemoryPointer.new(:int) 161 | root_y = FFI::MemoryPointer.new(:int) 162 | win_x = FFI::MemoryPointer.new(:int) 163 | win_y = FFI::MemoryPointer.new(:int) 164 | mask_return = FFI::MemoryPointer.new(:uint) 165 | 166 | X11.XQueryPointer(@display, @root_window, root_return, child_return, root_x, root_y, win_x, win_y, mask_return) 167 | 168 | { x: root_x.read_int, y: root_y.read_int } 169 | end 170 | 171 | def simulate_button_event(button, press) 172 | Xtst.XTestFakeButtonEvent(@display, button, press ? 1 : 0, 0) 173 | X11.XFlush(@display) 174 | end 175 | 176 | def finalize 177 | X11.XCloseDisplay(@display) if @display && !@display.null? 178 | end 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/mouses/mac_os_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ffi' 4 | require 'chromate/helpers' 5 | module Chromate 6 | module Hardwares 7 | module Mouses 8 | class MacOsController < MouseController 9 | class InvalidPlatformError < StandardError; end 10 | include Helpers 11 | extend FFI::Library 12 | 13 | ffi_lib '/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices' 14 | 15 | class CGPoint < FFI::Struct 16 | layout :x, :float, 17 | :y, :float 18 | end 19 | 20 | attach_function :CGEventCreateMouseEvent, [:pointer, :uint32, CGPoint.by_value, :uint32], :pointer 21 | attach_function :CGEventPost, %i[uint32 pointer], :void 22 | attach_function :CGEventSetType, %i[pointer uint32], :void 23 | attach_function :CFRelease, [:pointer], :void 24 | attach_function :CGMainDisplayID, [], :uint32 25 | attach_function :CGEventCreate, [:pointer], :pointer 26 | attach_function :CGEventGetLocation, [:pointer], CGPoint.by_value 27 | attach_function :CGDisplayPixelsHigh, [:uint32], :size_t 28 | 29 | class CGSize < FFI::Struct 30 | layout :width, :float, 31 | :height, :float 32 | end 33 | 34 | LEFT_DOWN = 1 35 | LEFT_UP = 2 36 | RIGHT_DOWN = 3 37 | RIGHT_UP = 4 38 | MOUSE_MOVED = 5 39 | 40 | def initialize(element: nil, client: nil) 41 | raise InvalidPlatformError, 'MouseController is only supported on macOS' unless mac? 42 | 43 | super 44 | @main_display = CGMainDisplayID() 45 | @display_height = CGDisplayPixelsHigh(@main_display).to_f 46 | @scale_factor = determine_scale_factor 47 | end 48 | 49 | def hover 50 | point = convert_coordinates(target_x, target_y) 51 | create_and_post_event(MOUSE_MOVED, point) 52 | current_mouse_position 53 | end 54 | 55 | def click 56 | current_pos = current_mouse_position 57 | create_and_post_event(LEFT_DOWN, current_pos) 58 | create_and_post_event(LEFT_UP, current_pos) 59 | end 60 | 61 | def right_click 62 | current_pos = current_mouse_position 63 | create_and_post_event(RIGHT_DOWN, current_pos) 64 | create_and_post_event(RIGHT_UP, current_pos) 65 | end 66 | 67 | def double_click 68 | click 69 | sleep(rand(DOUBLE_CLICK_DURATION_RANGE)) 70 | click 71 | end 72 | 73 | private 74 | 75 | def create_and_post_event(event_type, point) 76 | event = CGEventCreateMouseEvent(nil, event_type, point, 0) 77 | CGEventPost(0, event) 78 | CFRelease(event) 79 | end 80 | 81 | def current_mouse_position 82 | event = CGEventCreate(nil) 83 | return CGPoint.new if event.null? 84 | 85 | system_point = CGEventGetLocation(event) 86 | CFRelease(event) 87 | 88 | # Convert the system coordinates to browser coordinates 89 | browser_x = system_point[:x] / @scale_factor 90 | browser_y = (@display_height - system_point[:y]) / @scale_factor 91 | 92 | @mouse_position = { 93 | x: browser_x, 94 | y: browser_y 95 | } 96 | 97 | # Return the browser coordinates 98 | CGPoint.new.tap do |p| 99 | p[:x] = system_point[:x] 100 | p[:y] = system_point[:y] 101 | end 102 | end 103 | 104 | def convert_coordinates(browser_x, browser_y) 105 | # Convert the browser coordinates to system coordinates 106 | system_x = browser_x * @scale_factor 107 | system_y = @display_height - (browser_y * @scale_factor) 108 | 109 | CGPoint.new.tap do |p| 110 | p[:x] = system_x 111 | p[:y] = system_y 112 | end 113 | end 114 | 115 | def determine_scale_factor 116 | # Determine the scale factor for the display 117 | # By default, the scale factor is 2.0 for Retina displays 118 | 119 | `system_profiler SPDisplaysDataType | grep -i "retina"`.empty? ? 1.0 : 2.0 120 | rescue StandardError 121 | 2.0 # Default to 2.0 if the scale factor cannot be determined 122 | end 123 | end 124 | end 125 | end 126 | end 127 | 128 | # Test 129 | # require 'chromate/hardwares/mouses/mac_os_controller' 130 | # require 'ostruct' 131 | # element = OpenStruct.new(x: 500, y: 300, width: 100, height: 100) 132 | # mouse = Chromate::Hardwares::Mouse::MacOsController.new(element: element) 133 | # mouse.hover 134 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/mouses/virtual_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | module Hardwares 5 | module Mouses 6 | class VirtualController < Chromate::Hardwares::MouseController 7 | def hover # rubocop:disable Metrics/AbcSize 8 | # Define the target position 9 | target_x = element.x + (element.width / 2) + rand(-20..20) 10 | target_y = element.y + (element.height / 2) + rand(-20..20) 11 | start_x = mouse_position[:x] 12 | start_y = mouse_position[:y] 13 | steps = rand(25..50) 14 | duration = rand(0.1..0.3) 15 | 16 | # Generate a Bézier curve for natural movement 17 | points = bezier_curve(steps: steps, start_x: start_x, start_y: start_y, t_x: target_x, t_y: target_y) 18 | 19 | # Move the mouse along the Bézier curve 20 | points.each do |point| 21 | dispatch_mouse_event('mouseMoved', point[:x], point[:y]) 22 | sleep(duration / steps) 23 | end 24 | 25 | update_mouse_position(points.last[:x], points.last[:y]) 26 | end 27 | 28 | def click 29 | hover 30 | click! 31 | end 32 | 33 | def double_click 34 | click 35 | sleep(rand(DOUBLE_CLICK_DURATION_RANGE)) 36 | click 37 | end 38 | 39 | def right_click 40 | hover 41 | dispatch_mouse_event('mousePressed', target_x, target_y, button: 'right', click_count: 1) 42 | sleep(rand(CLICK_DURATION_RANGE)) 43 | dispatch_mouse_event('mouseReleased', target_x, target_y, button: 'right', click_count: 1) 44 | end 45 | 46 | # @param [Chromate::Element] element 47 | # @return [self] 48 | def drag_and_drop_to(element) # rubocop:disable Metrics/AbcSize 49 | hover 50 | 51 | target_x = element.x + (element.width / 2) 52 | target_y = element.y + (element.height / 2) 53 | start_x = mouse_position[:x] 54 | start_y = mouse_position[:y] 55 | steps = rand(25..50) 56 | duration = rand(0.1..0.3) 57 | 58 | # Generate a Bézier curve for natural movement 59 | points = bezier_curve(steps: steps, start_x: start_x, start_y: start_y, t_x: target_x, t_y: target_y) 60 | 61 | # Step 1: Start the drag (dragStart, dragEnter) 62 | move_mouse_to(start_x, start_y) 63 | dispatch_drag_event('dragEnter', start_x, start_y) 64 | 65 | # Step 2: Drag the element (dragOver) 66 | points.each do |point| 67 | move_mouse_to(point[:x], point[:y]) 68 | dispatch_drag_event('dragOver', point[:x], point[:y]) 69 | sleep(duration / steps) 70 | end 71 | 72 | # Step 3: Drop the element (drop) 73 | move_mouse_to(target_x, target_y) 74 | dispatch_drag_event('drop', target_x, target_y) 75 | 76 | # Step 4: End the drag (dragEnd) 77 | dispatch_drag_event('dragEnd', target_x, target_y) 78 | 79 | update_mouse_position(target_x, target_y) 80 | 81 | self 82 | end 83 | 84 | private 85 | 86 | # @return [self] 87 | def click! 88 | dispatch_mouse_event('mousePressed', target_x, target_y, button: 'left', click_count: 1) 89 | sleep(rand(CLICK_DURATION_RANGE)) 90 | dispatch_mouse_event('mouseReleased', target_x, target_y, button: 'left', click_count: 1) 91 | 92 | self 93 | end 94 | 95 | # @param [String] type mouseMoved, mousePressed, mouseReleased 96 | # @param [Integer] target_x 97 | # @param [Integer] target_y 98 | # @option [String] button 99 | # @option [Integer] click_count 100 | def dispatch_mouse_event(type, target_x, target_y, button: 'none', click_count: 0) 101 | params = { 102 | type: type, 103 | x: target_x, 104 | y: target_y, 105 | button: button, 106 | clickCount: click_count, 107 | deltaX: 0, 108 | deltaY: 0, 109 | modifiers: 0, 110 | timestamp: (Time.now.to_f * 1000).to_i 111 | } 112 | 113 | client.send_message('Input.dispatchMouseEvent', params) 114 | end 115 | 116 | # @param [Integer] steps 117 | # @param [Integer] x 118 | # @param [Integer] y 119 | # @return [Array] 120 | def dispatch_drag_event(type, x, y) # rubocop:disable Naming/MethodParameterName 121 | params = { 122 | type: type, 123 | x: x, 124 | y: y, 125 | data: { 126 | items: [ 127 | { 128 | mimeType: 'text/plain', 129 | data: 'dragged' 130 | } 131 | ], 132 | dragOperationsMask: 1 133 | } 134 | } 135 | 136 | client.send_message('Input.dispatchDragEvent', params) 137 | end 138 | 139 | # @param [Integer] x_target 140 | # @param [Integer] y_target 141 | # @return [self] 142 | def move_mouse_to(x_target, y_target) 143 | params = { 144 | type: 'mouseMoved', 145 | x: x_target, 146 | y: y_target, 147 | button: 'none', 148 | clickCount: 0, 149 | deltaX: 0, 150 | deltaY: 0, 151 | modifiers: 0, 152 | timestamp: (Time.now.to_f * 1000).to_i 153 | } 154 | 155 | client.send_message('Input.dispatchMouseEvent', params) 156 | 157 | self 158 | end 159 | end 160 | end 161 | end 162 | end 163 | 164 | # Test 165 | # require 'chromate/hardwares/mouses/virtual_controller' 166 | # require 'ostruct' 167 | # element = OpenStruct.new(x: 500, y: 300, width: 100, height: 100) 168 | # mouse = Chromate::Hardwares::Mouse::VirtualController.new(element: element) 169 | # mouse.hover 170 | -------------------------------------------------------------------------------- /lib/chromate/hardwares/mouses/x11.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ffi' 4 | require 'chromate/helpers' 5 | 6 | module X11 7 | extend FFI::Library 8 | ffi_lib 'X11' 9 | 10 | # Types 11 | typedef :ulong, :Window 12 | typedef :pointer, :Display 13 | 14 | # X11 functions 15 | attach_function :XOpenDisplay, [:string], :pointer 16 | attach_function :XCloseDisplay, [:pointer], :int 17 | attach_function :XDefaultRootWindow, [:pointer], :ulong 18 | attach_function :XWarpPointer, %i[pointer ulong ulong int int uint uint int int], :int 19 | attach_function :XQueryPointer, %i[pointer ulong pointer pointer pointer pointer pointer pointer pointer], :bool 20 | attach_function :XFlush, [:pointer], :int 21 | attach_function :XQueryTree, %i[pointer ulong pointer pointer pointer pointer], :int 22 | attach_function :XFetchName, %i[pointer ulong pointer], :int 23 | attach_function :XFree, [:pointer], :int 24 | attach_function :XRaiseWindow, %i[pointer ulong], :int 25 | attach_function :XSetInputFocus, %i[pointer ulong int ulong], :int 26 | 27 | # Constants 28 | REVERT_TO_PARENT = 2 29 | end 30 | 31 | module Xtst 32 | extend FFI::Library 33 | ffi_lib 'Xtst' 34 | 35 | attach_function :XTestFakeButtonEvent, %i[pointer uint int ulong], :int 36 | end 37 | -------------------------------------------------------------------------------- /lib/chromate/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rbconfig' 4 | 5 | module Chromate 6 | module Helpers 7 | # @return [Boolean] 8 | def linux? 9 | RbConfig::CONFIG['host_os'] =~ /linux|bsd/i 10 | end 11 | 12 | # @return [Boolean] 13 | def mac? 14 | RbConfig::CONFIG['host_os'] =~ /darwin/i 15 | end 16 | 17 | # @return [Boolean] 18 | def windows? 19 | RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/i 20 | end 21 | 22 | # @return [Integer] 23 | def find_available_port 24 | server = TCPServer.new('127.0.0.1', 0) 25 | port = server.addr[1] 26 | server.close 27 | port 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/chromate/user_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | class UserAgent 5 | # @return [String] 6 | def self.call 7 | case os 8 | when 'Linux' 9 | linux_agent 10 | when 'Mac' 11 | mac_agent 12 | when 'Windows' 13 | windows_agent 14 | else 15 | raise 'Unknown OS' 16 | end 17 | end 18 | 19 | # @return [String<'Mac', 'Linux', 'Windows', 'Unknown'>] 20 | def self.os 21 | case RUBY_PLATFORM 22 | when /darwin/ 23 | 'Mac' 24 | when /linux/ 25 | 'Linux' 26 | when /mswin|msys|mingw|cygwin|bccwin|wince|emc/ 27 | 'Windows' 28 | else 29 | 'Unknown' 30 | end 31 | end 32 | 33 | def self.arch 34 | case RUBY_PLATFORM 35 | when /x64-mingw32/ 36 | 'x64' 37 | when /x86_64-mingw32/ 38 | 'x86_64' 39 | else 40 | 'x86' 41 | end 42 | end 43 | 44 | def self.agents 45 | @agents ||= JSON.parse(File.read(File.join(__dir__, 'files/agents.json'))) 46 | end 47 | 48 | def self.linux_agent 49 | agents['linux'].sample 50 | end 51 | 52 | def self.mac_agent 53 | agents['mac'].sample 54 | end 55 | 56 | def self.windows_agent 57 | agents['windows'].sample 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/chromate/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Chromate 4 | VERSION = '0.0.7.pre' 5 | end 6 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/logo.png -------------------------------------------------------------------------------- /results/bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/results/bot.png -------------------------------------------------------------------------------- /results/brotector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/results/brotector.png -------------------------------------------------------------------------------- /results/cloudflare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/results/cloudflare.png -------------------------------------------------------------------------------- /results/headers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/results/headers.png -------------------------------------------------------------------------------- /results/pixelscan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/results/pixelscan.png -------------------------------------------------------------------------------- /sig/chromate.rbs: -------------------------------------------------------------------------------- 1 | module Chromate 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /spec/apps/dom_actions/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chromate Actions Test Page 7 | 12 | 13 | 14 | 15 |

Chromate Actions Test Page

16 | 17 | 18 | 19 |

Button not clicked yet

20 | 21 | 22 |
23 | Hover over me 24 |
25 | 26 | 27 |
28 | 29 | 30 |
31 |

No input yet

32 | 33 | 34 |

This is a text element

35 | 36 | 37 | 43 |

No option selected

44 | 45 | 46 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /spec/apps/drag_and_drop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test Drag and Drop avec suivi de la souris 6 | 49 | 50 | 51 | 52 |
Drag Me
53 |
Drop Here
54 | 55 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /spec/apps/fill_form/clickViewer.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: black; 4 | position: relative; 5 | } 6 | .click-point { 7 | position: absolute; 8 | width: 20px; 9 | height: 20px; 10 | border-radius: 50%; 11 | color: white; 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | font-size: 12px; 16 | font-family: Arial, sans-serif; 17 | } 18 | .left-click { 19 | background-color: red; 20 | } 21 | .right-click { 22 | background-color: blue; 23 | } -------------------------------------------------------------------------------- /spec/apps/fill_form/clickViewer.js: -------------------------------------------------------------------------------- 1 | let clickCount = 0; 2 | 3 | document.addEventListener('click', (event) => { 4 | clickCount++; 5 | createClickPoint(event.pageX, event.pageY, 'left-click', clickCount); 6 | }); 7 | 8 | document.addEventListener('contextmenu', (event) => { 9 | event.preventDefault(); 10 | clickCount++; 11 | createClickPoint(event.pageX, event.pageY, 'right-click', clickCount); 12 | }); 13 | 14 | document.getElementById('interactive-button').addEventListener('click', (event) => { 15 | event.target.style.backgroundColor = 'green'; 16 | event.target.textContent = 'Clicked!'; 17 | }); 18 | 19 | function createClickPoint(x, y, clickType, count) { 20 | const point = document.createElement('div'); 21 | point.classList.add('click-point', clickType); 22 | point.style.left = `${x - 10}px`; 23 | point.style.top = `${y - 10}px`; 24 | point.textContent = count; 25 | document.body.appendChild(point); 26 | } -------------------------------------------------------------------------------- /spec/apps/fill_form/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | User Profile Creation 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

User Profile Creation

15 | 16 |
17 | 18 |
19 |

Personal Information

20 | 21 |
22 |
23 | 24 | 26 |
27 |
28 | 29 | 31 |
32 |
33 | 34 |
35 |
36 | 37 | 39 |
40 |
41 | 42 | 49 |
50 |
51 |
52 | 53 | 54 |
55 |

Contact

56 | 57 |
58 | 59 | 61 |
62 | 63 |
64 | 65 | 67 |
68 |
69 | 70 | 71 |
72 |

Preferences

73 | 74 |
75 | 76 |
77 | 81 | 85 | 89 |
90 |
91 | 92 |
93 | 94 | 96 |
97 |
98 | 99 |
100 | 104 |
105 |
106 | 107 | 108 | 122 |
123 | 124 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /spec/apps/shadow_checkbox/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Blog Simple 7 | 44 | 45 | 46 | 47 |
48 |
49 |

Bienvenue sur mon blog

50 |
51 | 52 |
53 |

Article 1 : Les bienfaits de la méditation

54 |

55 | La méditation est une pratique ancienne qui aide à réduire le stress et à améliorer la concentration. 56 | En prenant quelques minutes chaque jour pour méditer, vous pouvez transformer votre vie de manière 57 | positive et significative. 58 |

59 |
60 | 61 |
62 |

Article 2 : Les secrets d'une alimentation équilibrée

63 |

64 | Une alimentation équilibrée est essentielle pour maintenir une bonne santé. 65 | Manger une variété d'aliments riches en nutriments peut vous aider à rester en forme et plein d'énergie. 66 | Ne sous-estimez pas l'importance de consommer des fruits, des légumes et des protéines de qualité ! 67 |

68 |
69 | 70 |
71 |

Section avec Shadow DOM

72 |

Cette section est rendue via un Shadow DOM pour une encapsulation du style et du comportement.

73 |
74 |
75 | 76 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /spec/apps/where_clicked/clickViewer.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: black; 4 | position: relative; 5 | } 6 | .click-point { 7 | position: absolute; 8 | width: 20px; 9 | height: 20px; 10 | border-radius: 50%; 11 | color: white; 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | font-size: 12px; 16 | font-family: Arial, sans-serif; 17 | } 18 | .left-click { 19 | background-color: red; 20 | } 21 | .right-click { 22 | background-color: blue; 23 | } -------------------------------------------------------------------------------- /spec/apps/where_clicked/clickViewer.js: -------------------------------------------------------------------------------- 1 | let clickCount = 0; 2 | 3 | document.addEventListener('click', (event) => { 4 | clickCount++; 5 | createClickPoint(event.pageX, event.pageY, 'left-click', clickCount); 6 | }); 7 | 8 | document.addEventListener('contextmenu', (event) => { 9 | event.preventDefault(); 10 | clickCount++; 11 | createClickPoint(event.pageX, event.pageY, 'right-click', clickCount); 12 | }); 13 | 14 | document.getElementById('interactive-button').addEventListener('click', (event) => { 15 | event.target.style.backgroundColor = 'green'; 16 | event.target.textContent = 'Clicked!'; 17 | }); 18 | 19 | function createClickPoint(x, y, clickType, count) { 20 | const point = document.createElement('div'); 21 | point.classList.add('click-point', clickType); 22 | point.style.left = `${x - 10}px`; 23 | point.style.top = `${y - 10}px`; 24 | point.textContent = count; 25 | document.body.appendChild(point); 26 | } -------------------------------------------------------------------------------- /spec/apps/where_clicked/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mouse Click Visualizer 6 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /spec/apps/where_moved/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Traceur de souris avec boutons 6 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /spec/browser/dom_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Dom actions' do 6 | let(:browser) { Chromate::Browser.new(browser_args) } 7 | 8 | before(:each) do 9 | browser.start 10 | @url = server_urls['dom_actions'] 11 | browser.navigate_to(@url) 12 | end 13 | 14 | after(:each) do 15 | browser.stop 16 | end 17 | 18 | it 'clicks a button' do 19 | button = browser.find_element('#click-button') 20 | button.click 21 | 22 | result = browser.find_element('#click-result') 23 | expect(result.text).to eq('Button clicked!') 24 | end 25 | 26 | it 'hovers over an element' do 27 | hover_box = browser.find_element('#hover-box') 28 | 29 | hover_box.hover 30 | 31 | expect(hover_box.attributes['class']).to include('hover-highlight') 32 | end 33 | 34 | it 'types text into an input' do 35 | input = browser.find_element('#input-text') 36 | input.type('Testing Chromate') 37 | 38 | result = browser.find_element('#input-result') 39 | expect(result.text).to eq('No input yet') 40 | 41 | input.press_enter 42 | expect(result.text).to eq('Input submitted: Testing Chromate') 43 | end 44 | 45 | it 'presses the Enter key' do 46 | input = browser.find_element('#input-text') 47 | input.type('Pressing Enter Test') 48 | input.press_enter 49 | 50 | result = browser.find_element('#input-result') 51 | expect(result.text).to eq('Input submitted: Pressing Enter Test') 52 | end 53 | 54 | it 'selects an option from a dropdown' do 55 | browser.find_element('#test-select').select_option('option2') 56 | 57 | result = browser.find_element('#select-result') 58 | expect(result.text).to eq('Selected option: option2') 59 | end 60 | 61 | it 'evaluates a JavaScript expression' do 62 | result = browser.evaluate_script('document.title') 63 | expect(result).to eq('Chromate Actions Test Page') 64 | end 65 | 66 | it 'gets the source of the page' do 67 | source = browser.source 68 | expect(source).to include('Chromate Actions Test Page') 69 | end 70 | 71 | it 'captures a screenshot' do 72 | screenshot = browser.screenshot 73 | expect(screenshot).to match(hash_including(path: kind_of(String), base64: kind_of(String))) 74 | end 75 | 76 | it 'captures a screenshot of the full page' do 77 | screenshot = browser.screenshot(nil, full_page: true) 78 | expect(screenshot).to match(hash_including(path: kind_of(String), base64: kind_of(String))) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/browser/form_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Form' do 6 | let(:browser) { Chromate::Browser.new(browser_args) } 7 | 8 | it 'fills a full profile form' do 9 | browser.start 10 | url = server_urls['fill_form'] 11 | browser.navigate_to(url) 12 | 13 | # Personnal informations 14 | browser.find_element('#first-name').type('John') 15 | browser.find_element('#last-name').type('Doe') 16 | browser.find_element('#birthdate').type('15/05/1990') 17 | select_tag = browser.find_element('#gender') 18 | select_tag.select_option('other') 19 | expect(select_tag.selected_value).to eq('other') 20 | expect(select_tag.selected_text).to eq('Other') 21 | 22 | # Contact 23 | browser.find_element('#email').type('john.doe@example.com') 24 | browser.find_element('#phone').type('+555123456789') 25 | 26 | # Preferences 27 | # Interests 28 | browser.find_element('input[value="sport"]').click 29 | browser.find_element('input[value="tech"]').click 30 | 31 | # Biography 32 | browser.find_element('#bio').type('I am a passionate developer interested in new technologies and sports.') 33 | 34 | # Submit form 35 | browser.find_element('button[type="submit"]').click 36 | message = browser.find_element('#confirmation-message') 37 | expect(message.text).to include('Profile created successfully!') 38 | 39 | # Capture screenshot 40 | browser.screenshot('spec/apps/fill_form/profile_form.png') 41 | 42 | browser.stop 43 | expect(File.exist?('spec/apps/fill_form/profile_form.png')).to be true 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/browser/mouse_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Mouse' do 6 | let(:browser) { Chromate::Browser.new(browser_args) } 7 | 8 | it 'clicks the button' do 9 | browser.start 10 | url = server_urls['where_clicked'] 11 | browser.navigate_to(url) 12 | browser.find_element('#interactive-button').click 13 | browser.screenshot('spec/apps/where_clicked/click.png') 14 | 15 | browser.stop 16 | expect(File.exist?('spec/apps/where_clicked/click.png')).to be true 17 | end 18 | 19 | it 'moves the mouse to the red button' do 20 | browser.start 21 | url = server_urls['where_moved'] 22 | browser.navigate_to(url) 23 | browser.find_element('#red').hover 24 | browser.screenshot('spec/apps/where_moved/hover_element_red.png') 25 | 26 | browser.stop 27 | expect(File.exist?('spec/apps/where_moved/hover_element_red.png')).to be true 28 | end 29 | 30 | it 'moves the mouse to the blue button' do 31 | browser.start 32 | url = server_urls['where_moved'] 33 | browser.navigate_to(url) 34 | browser.find_element('#blue').hover 35 | browser.screenshot('spec/apps/where_moved/hover_element_blue.png') 36 | 37 | browser.stop 38 | expect(File.exist?('spec/apps/where_moved/hover_element_blue.png')).to be true 39 | end 40 | 41 | it 'moves the mouse to the green button' do 42 | browser.start 43 | url = server_urls['where_moved'] 44 | browser.navigate_to(url) 45 | browser.find_element('#green').hover 46 | browser.screenshot('spec/apps/where_moved/hover_element_green.png') 47 | 48 | browser.stop 49 | expect(File.exist?('spec/apps/where_moved/hover_element_green.png')).to be true 50 | end 51 | 52 | it 'moves the mouse to the yellow button' do 53 | browser.start 54 | url = server_urls['where_moved'] 55 | browser.navigate_to(url) 56 | browser.find_element('#yellow').hover 57 | browser.screenshot('spec/apps/where_moved/hover_element_yellow.png') 58 | 59 | browser.stop 60 | expect(File.exist?('spec/apps/where_moved/hover_element_yellow.png')).to be true 61 | end 62 | 63 | it 'moves the mouse to all buttons' do 64 | browser.start 65 | url = server_urls['where_moved'] 66 | browser.navigate_to(url) 67 | browser.find_element('#red').hover 68 | browser.find_element('#yellow').hover 69 | browser.find_element('#green').hover 70 | browser.find_element('#blue').hover 71 | browser.screenshot('spec/apps/where_moved/hover_element_all.png') 72 | 73 | browser.stop 74 | expect(File.exist?('spec/apps/where_moved/hover_element_all.png')).to be true 75 | end 76 | 77 | it 'drag and drop the blue square to the green square' do 78 | browser.start 79 | url = server_urls['drag_and_drop'] 80 | browser.navigate_to(url) 81 | blue_square = browser.find_element('#draggable') 82 | green_square = browser.find_element('#dropzone') 83 | 84 | expect(green_square.text).to eq('Drop Here') 85 | 86 | blue_square.drop_to(green_square) 87 | 88 | expect(green_square.text).to eq('Dropped!') 89 | 90 | browser.screenshot('spec/apps/drag_and_drop/droped.png') 91 | 92 | browser.stop 93 | expect(File.exist?('spec/apps/drag_and_drop/droped.png')).to be true 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/browser/shadow_dom_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Shadow dom' do 6 | let(:browser) { Chromate::Browser.new(browser_args) } 7 | 8 | it 'fills the form' do 9 | browser.start 10 | url = server_urls['shadow_checkbox'] 11 | browser.navigate_to(url) 12 | shadow_container = browser.find_element('#shadow-container') 13 | expect(shadow_container).to be_shadow_root 14 | checkbox = shadow_container.find_shadow_child('#shadow-checkbox') 15 | expect(checkbox).to be_a(Chromate::Element) 16 | checkbox.click 17 | 18 | browser.screenshot('spec/apps/shadow_checkbox/click.png') 19 | 20 | browser.stop 21 | expect(File.exist?('spec/apps/shadow_checkbox/click.png')).to be_truthy 22 | end 23 | 24 | it 'logs into the secure area' do 25 | browser.start 26 | url = server_urls['complex_login'] 27 | browser.navigate_to(url) 28 | # Find and click the secure login container 29 | secure_login = browser.find_element('secure-login') 30 | locked_overlay = secure_login.find_shadow_child('#locked-overlay') 31 | locked_overlay.click 32 | 33 | # Handle the security challenge 34 | challenge_code = secure_login.find_shadow_child('#challenge-code').text 35 | challenge_input = secure_login.find_shadow_child('#challenge-input') 36 | challenge_input.type(challenge_code) 37 | verify_button = secure_login.find_shadow_child('#verify-challenge') 38 | verify_button.click 39 | 40 | # Fill in the login form 41 | username_input = secure_login.find_shadow_child('#username') 42 | password_input = secure_login.find_shadow_child('#password') 43 | username_input.type('admin') 44 | password_input.type('password') 45 | 46 | # Submit the login form 47 | login_form = secure_login.find_shadow_child('#login-form') 48 | login_form.find_element('button').click 49 | secure_zone = secure_login.find_shadow_child('#secure-zone') 50 | expect(secure_zone.text).to include('Accès autorisé') 51 | 52 | # Take a screenshot of the secure zone 53 | browser.screenshot('spec/apps/complex_login/secure_zone.png') 54 | 55 | browser.stop 56 | expect(File.exist?('spec/apps/complex_login/secure_zone.png')).to be true 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/chromate/binary_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Binary do 6 | describe '#run' do 7 | context 'when command exists' do 8 | it 'returns the stdout' do 9 | expect(Chromate::Binary.run('echo', ['hello'])).to eq "hello\n" 10 | end 11 | end 12 | 13 | context 'when command does not exist' do 14 | it 'raises an error' do 15 | expect { Chromate::Binary.run('invalid_command', []) }.to raise_error(Errno::ENOENT, 'No such file or directory - invalid_command') 16 | end 17 | end 18 | 19 | context 'when command fails and need_success is false' do 20 | it 'does not raise an error' do 21 | expect { Chromate::Binary.run('false', [], need_success: false) }.not_to raise_error 22 | end 23 | end 24 | 25 | context 'when command fails and need_success is true' do 26 | it 'raises an error' do 27 | expect { Chromate::Binary.run('false', [], need_success: true) }.to raise_error(RuntimeError) 28 | end 29 | end 30 | end 31 | 32 | describe '#start' do 33 | let(:binary) { Chromate::Binary.new('echo', ['hello']) } 34 | 35 | it 'starts the process' do 36 | expect(binary.start).to be_a(Chromate::Binary) 37 | end 38 | 39 | it 'sets the pid' do 40 | expect(binary.start.pid).to be_a(Integer) 41 | end 42 | 43 | it 'is started' do 44 | expect(binary.start).to be_started 45 | end 46 | 47 | it 'returns self' do 48 | expect(binary.start).to eq(binary) 49 | end 50 | 51 | context 'when success starting the process' do 52 | let(:binary) { Chromate::Binary.new('tail', ['-f', '/dev/null']) } 53 | 54 | it 'starts and stops the process' do 55 | binary.start 56 | sleep 0.5 57 | pid = binary.pid 58 | 59 | expect(binary.started?).to be(true) 60 | expect(Process.getpgid(pid)).to be_a(Integer) 61 | 62 | binary.stop 63 | expect(binary.started?).to be(false) 64 | expect { Process.getpgid(pid) }.to raise_error(Errno::ESRCH) 65 | end 66 | end 67 | 68 | context 'when failing to start the process' do 69 | let(:binary) { Chromate::Binary.new('invalid_command', []) } 70 | 71 | it 'raises an error' do 72 | expect { binary.start }.to raise_error(Errno::ENOENT) 73 | end 74 | end 75 | 76 | context 'when process raises an error' do 77 | let(:binary) { Chromate::Binary.new('sh', ['-c', 'sleep 1; exit 1']) } 78 | 79 | it 'does not raise an error' do 80 | expect { binary.start }.not_to raise_error 81 | expect(binary.started?).to be(true) 82 | sleep 1.5 83 | 84 | expect(binary).not_to be_running 85 | end 86 | end 87 | end 88 | 89 | describe '#stop' do 90 | let(:binary) { Chromate::Binary.new('tail', ['-f', '/dev/null']) } 91 | 92 | before { binary.start } 93 | 94 | it 'stops the process' do 95 | sleep 1 96 | binary.stop 97 | expect(binary).not_to be_running 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/chromate/browser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Browser do 6 | subject(:browser) { described_class.new(options) } 7 | 8 | let(:client) { instance_double(Chromate::Client, port: 1234, browser: browser) } 9 | let(:config) { instance_double(Chromate::Configuration, options: {}, exclude_switches: [], patch?: false) } 10 | let(:options) do 11 | { 12 | chrome_path: '/path/to/chrome', 13 | user_data_dir: '/tmp/test_user_data', 14 | headless: true, 15 | xfvb: false, 16 | native_control: false, 17 | record: false 18 | } 19 | end 20 | let(:browser_args) { ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-gpu-compositing', '--disable-features=site-per-process'] } 21 | 22 | before do 23 | allow(Chromate::Client).to receive(:new).and_return(client) 24 | allow(client).to receive(:start) 25 | allow(client).to receive(:stop) 26 | allow(client).to receive(:reconnect) 27 | allow(client).to receive(:send_message) 28 | allow(config).to receive(:generate_arguments).and_return(browser_args) 29 | allow(config).to receive(:args=) 30 | allow(config).to receive(:user_data_dir=) 31 | allow(config).to receive(:headless=) 32 | allow(config).to receive(:xfvb=) 33 | allow(config).to receive(:native_control=) 34 | allow(config).to receive(:mouse_controller=) 35 | allow(config).to receive(:keyboard_controller=) 36 | end 37 | 38 | describe '#initialize' do 39 | it 'sets up default options' do 40 | expect(browser.options).to include( 41 | chrome_path: '/path/to/chrome', 42 | headless: true, 43 | xfvb: false, 44 | native_control: false, 45 | record: false 46 | ) 47 | end 48 | end 49 | 50 | describe '#started?' do 51 | let(:binary_double) { instance_double(Chromate::Binary, started?: true) } 52 | 53 | before do 54 | allow(Chromate::Binary).to receive(:new).with('/path/to/chrome', kind_of(Array)).and_return(binary_double) 55 | allow(binary_double).to receive(:start) 56 | allow(binary_double).to receive(:started?).and_return(true) 57 | 58 | browser.start 59 | end 60 | 61 | it { expect(browser).to be_started } 62 | end 63 | 64 | describe '#start' do 65 | let(:binary_double) { instance_double(Chromate::Binary, started?: true) } 66 | 67 | before do 68 | allow(Chromate::Binary).to receive(:new).with('/path/to/chrome', kind_of(Array)).and_return(binary_double) 69 | allow(binary_double).to receive(:start) 70 | allow(binary_double).to receive(:started?).and_return(true) 71 | end 72 | 73 | it 'initializes and starts the browser components' do 74 | expect(Chromate::Binary).to receive(:new).with('/path/to/chrome', array_including('--remote-debugging-port=1234')) 75 | expect(binary_double).to receive(:start) 76 | expect(client).to receive(:start) 77 | expect(Chromate::Hardwares::MouseController).to receive(:reset_mouse_position) 78 | 79 | browser.start 80 | end 81 | 82 | context 'when xfvb is enabled' do 83 | let(:options) { super().merge(xfvb: true) } 84 | let(:original_display) { ENV.fetch('DISPLAY', nil) } 85 | 86 | after { ENV['DISPLAY'] = original_display } 87 | 88 | it 'sets DISPLAY environment variable for Mac' do 89 | ENV['DISPLAY'] = nil 90 | allow(browser).to receive(:mac?).and_return(true) 91 | allow(browser).to receive(:linux?).and_return(false) 92 | 93 | browser.start 94 | 95 | expect(ENV.fetch('DISPLAY', nil)).to eq(':0') 96 | end 97 | 98 | it 'sets DISPLAY environment variable for Linux' do 99 | ENV['DISPLAY'] = nil 100 | allow(browser).to receive(:mac?).and_return(false) 101 | allow(browser).to receive(:linux?).and_return(true) 102 | 103 | browser.start 104 | 105 | expect(ENV.fetch('DISPLAY', nil)).to eq(':99') 106 | end 107 | 108 | it 'adds display argument to chrome args' do 109 | ENV['DISPLAY'] = ':1' 110 | expect(Chromate::Binary).to receive(:new) 111 | .with('/path/to/chrome', array_including('--display=:1')) 112 | 113 | browser.start 114 | end 115 | end 116 | 117 | context 'when recording is enabled' do 118 | let(:options) { super().merge(record: true) } 119 | let(:ffmpeg_binary) { instance_double(Chromate::Binary, pid: 12_345) } 120 | 121 | before do 122 | allow(Chromate::Binary).to receive(:new) 123 | .with('ffmpeg', kind_of(Array)) 124 | .and_return(ffmpeg_binary) 125 | allow(ffmpeg_binary).to receive(:start) 126 | end 127 | 128 | it 'starts video recording' do 129 | expect(Chromate::Binary).to receive(:new) 130 | .with('ffmpeg', array_including('-f', 'x11grab')) 131 | expect(ffmpeg_binary).to receive(:start) 132 | 133 | browser.start 134 | end 135 | end 136 | end 137 | 138 | describe '#stop' do 139 | let(:binary_double) { instance_double(Chromate::Binary, started?: true) } 140 | let(:record_pid) { nil } 141 | 142 | before do 143 | allow(Chromate::Binary).to receive(:new).with('/path/to/chrome', kind_of(Array)).and_return(binary_double) 144 | allow(binary_double).to receive(:start) 145 | allow(binary_double).to receive(:stop) 146 | 147 | browser.start 148 | end 149 | 150 | it 'stops the browser components' do 151 | browser.stop 152 | 153 | expect(client).to have_received(:stop) 154 | expect(binary_double).to have_received(:stop) 155 | end 156 | 157 | it 'cleans up instance variables' do 158 | browser.stop 159 | 160 | expect(browser.instance_variable_get(:@binary)).to be_nil 161 | expect(browser.instance_variable_get(:@record_process)).to be_nil 162 | end 163 | 164 | context 'when recording is active' do 165 | let(:record_pid) { 12_345 } 166 | 167 | before do 168 | browser.instance_variable_set(:@record_process, record_pid) 169 | allow(Process).to receive(:kill) 170 | allow(Process).to receive(:wait) 171 | end 172 | 173 | it 'stops the recording process' do 174 | browser.stop 175 | 176 | expect(Process).to have_received(:kill).with('INT', record_pid) 177 | expect(Process).to have_received(:wait).with(record_pid) 178 | end 179 | 180 | context 'when process does not stop gracefully' do 181 | before do 182 | allow(Process).to receive(:wait).and_raise(Timeout::Error) 183 | allow(Process).to receive(:kill).with('KILL', record_pid) 184 | end 185 | 186 | it 'forces process termination with SIGKILL' do 187 | browser.stop 188 | 189 | expect(Process).to have_received(:kill).with('KILL', record_pid) 190 | expect(Process).to have_received(:wait).with(record_pid) 191 | end 192 | end 193 | end 194 | end 195 | 196 | describe '#native_control?' do 197 | context 'when native_control is enabled' do 198 | let(:options) { super().merge(native_control: true) } 199 | it { expect(browser.native_control?).to be true } 200 | end 201 | 202 | context 'when native_control is disabled' do 203 | let(:options) { super().merge(native_control: false) } 204 | it { expect(browser.native_control?).to be false } 205 | end 206 | end 207 | 208 | describe '#stop_and_exit' do 209 | it 'stops the browser and exits' do 210 | browser.send(:stop_and_exit) 211 | 212 | expect(browser).to have_received(:stop) 213 | expect(browser).to have_received(:exit) 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/chromate/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Configuration do 6 | let(:config) { Chromate::Configuration.new } 7 | 8 | it 'has a default user data dir' do 9 | expect(config.user_data_dir).to eq File.expand_path('~/.config/google-chrome/Default') 10 | end 11 | 12 | it 'has a default headless setting' do 13 | expect(config.headless).to eq true 14 | end 15 | 16 | it 'has a default xfvb setting' do 17 | expect(config.xfvb).to eq false 18 | end 19 | 20 | it 'has a default native control setting' do 21 | expect(config.native_control).to eq false 22 | end 23 | 24 | it 'has a default proxy setting' do 25 | expect(config.proxy).to eq nil 26 | end 27 | 28 | it 'has default headless args' do 29 | expect(config.headless_args).to eq Chromate::Configuration::HEADLESS_ARGS 30 | end 31 | 32 | it 'has default xfvb args' do 33 | expect(config.xfvb_args).to eq Chromate::Configuration::XVFB_ARGS 34 | end 35 | 36 | it 'has default disabled features' do 37 | expect(config.disable_features).to eq Chromate::Configuration::DISABLED_FEATURES 38 | end 39 | 40 | it 'has default exclude switches' do 41 | expect(config.exclude_switches).to eq Chromate::Configuration::EXCLUDE_SWITCHES 42 | end 43 | 44 | it 'can be configured with a block' do 45 | Chromate.configure do |config| 46 | config.user_data_dir = 'foo' 47 | end 48 | 49 | expect(Chromate.configuration.user_data_dir).to eq 'foo' 50 | end 51 | 52 | describe '#generate_arguments' do 53 | it 'generates arguments with headless' do 54 | expect(config.generate_arguments(headless: true)).to include('--headless=new') 55 | end 56 | 57 | it 'generates arguments with xfvb' do 58 | expect(config.generate_arguments(xfvb: true)).to include('--disable-gpu') 59 | end 60 | 61 | it 'generates arguments with proxy' do 62 | expect(config.generate_arguments(proxy: { host: 'foo', port: 1234 })).to include('--proxy-server=foo:1234') 63 | end 64 | 65 | it 'generates arguments with disable features' do 66 | expect(config.generate_arguments(disable_features: ['foo'])).to include('--disable-features=foo') 67 | end 68 | end 69 | 70 | context 'when on a linux' do 71 | before { allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('linux') } 72 | 73 | it 'has default args' do 74 | expect(config.args).to eq Chromate::Configuration::DEFAULT_ARGS 75 | end 76 | 77 | describe '#chrome_path' do 78 | let(:path) { ENV.fetch('CHROME_BIN', '/usr/bin/google-chrome-stable') } 79 | 80 | it 'returns the path to chrome' do 81 | expect(config.chrome_path).to eq path 82 | end 83 | end 84 | end 85 | 86 | context 'when on a mac' do 87 | before { allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('darwin') } 88 | 89 | it 'has default args' do 90 | expect(config.args).to eq Chromate::Configuration::DEFAULT_ARGS + ['--use-angle=metal'] 91 | end 92 | 93 | describe '#chrome_path' do 94 | let(:path) { ENV.fetch('CHROME_BIN', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') } 95 | 96 | it 'returns the path to chrome' do 97 | expect(config.chrome_path).to eq path 98 | end 99 | end 100 | end 101 | 102 | context 'when on windows' do 103 | before { allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('mswin') } 104 | 105 | it 'has default args' do 106 | expect(config.args).to eq Chromate::Configuration::DEFAULT_ARGS 107 | end 108 | 109 | describe '#chrome_path' do 110 | let(:path) { ENV.fetch('CHROME_BIN', 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe') } 111 | 112 | it 'returns the path to chrome' do 113 | expect(config.chrome_path).to eq path 114 | end 115 | end 116 | end 117 | 118 | context 'when on an unsupported platform' do 119 | before do 120 | allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('foo') 121 | allow(ENV).to receive(:[]).with('CHROME_BIN').and_return(nil) 122 | end 123 | 124 | describe '#chrome_path' do 125 | it 'raises an exception' do 126 | expect { config.chrome_path }.to raise_error Chromate::Exceptions::InvalidPlatformError 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/chromate/elements/checkbox_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Elements::Checkbox do 6 | let(:selector) { '#test-checkbox' } 7 | let(:client) { instance_double(Chromate::Client) } 8 | let(:mouse_controller) { instance_double(Chromate::Hardwares::MouseController) } 9 | let(:keyboard_controller) { instance_double(Chromate::Hardwares::KeyboardController) } 10 | let(:node_id) { 123 } 11 | let(:object_id) { 'object-123' } 12 | let(:root_id) { 456 } 13 | 14 | let(:configuration) do 15 | instance_double(Chromate::Configuration, 16 | mouse_controller: mouse_controller, 17 | keyboard_controller: keyboard_controller) 18 | end 19 | 20 | before do 21 | allow(Chromate).to receive(:configuration).and_return(configuration) 22 | allow(mouse_controller).to receive(:set_element).and_return(mouse_controller) 23 | allow(keyboard_controller).to receive(:set_element).and_return(keyboard_controller) 24 | mock_default_element_responses(client, root_id: root_id, node_id: node_id, object_id: object_id) 25 | end 26 | 27 | describe '#initialize' do 28 | context 'with valid checkbox element' do 29 | before do 30 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 31 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'checkbox' }) 32 | end 33 | 34 | it 'creates a new checkbox instance' do 35 | expect { described_class.new(selector, client) }.not_to raise_error 36 | end 37 | end 38 | 39 | context 'with invalid element' do 40 | before do 41 | mock_element_tag_name(client, object_id: object_id, tag_name: 'div') 42 | end 43 | 44 | it 'raises InvalidSelectorError' do 45 | expect { described_class.new(selector, client) } 46 | .to raise_error(Chromate::Element::InvalidSelectorError) 47 | end 48 | end 49 | end 50 | 51 | describe '#checkbox?' do 52 | subject(:checkbox) { described_class.new(selector, client) } 53 | 54 | before do 55 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 56 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'checkbox' }) 57 | end 58 | 59 | it 'returns true for checkbox elements' do 60 | expect(checkbox.checkbox?).to be true 61 | end 62 | end 63 | 64 | describe '#checked?' do 65 | subject(:checkbox) { described_class.new(selector, client) } 66 | 67 | before do 68 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 69 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'checkbox', 'checked' => checked }) 70 | end 71 | 72 | context 'when checkbox is checked' do 73 | let(:checked) { 'true' } 74 | 75 | it 'returns true' do 76 | expect(checkbox.checked?).to be true 77 | end 78 | end 79 | 80 | context 'when checkbox is unchecked' do 81 | let(:checked) { nil } 82 | 83 | it 'returns false' do 84 | expect(checkbox.checked?).to be false 85 | end 86 | end 87 | end 88 | 89 | describe '#check' do 90 | subject(:checkbox) { described_class.new(selector, client) } 91 | 92 | before do 93 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 94 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'checkbox', 'checked' => checked }) 95 | mock_element_click(client, node_id: node_id) 96 | allow(mouse_controller).to receive(:click) 97 | end 98 | 99 | context 'when checkbox is already checked' do 100 | let(:checked) { 'true' } 101 | 102 | it 'does not click the checkbox' do 103 | checkbox.check 104 | expect(mouse_controller).not_to have_received(:click) 105 | end 106 | end 107 | 108 | context 'when checkbox is unchecked' do 109 | let(:checked) { nil } 110 | 111 | it 'clicks the checkbox' do 112 | checkbox.check 113 | expect(mouse_controller).to have_received(:click) 114 | end 115 | end 116 | 117 | context 'when checking for method chaining' do 118 | let(:checked) { nil } 119 | 120 | it 'returns self for method chaining' do 121 | expect(checkbox.check).to eq(checkbox) 122 | end 123 | end 124 | end 125 | 126 | describe '#uncheck' do 127 | subject(:checkbox) { described_class.new(selector, client) } 128 | 129 | before do 130 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 131 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'checkbox', 'checked' => checked }) 132 | mock_element_click(client, node_id: node_id) 133 | allow(mouse_controller).to receive(:click) 134 | end 135 | 136 | context 'when checkbox is checked' do 137 | let(:checked) { 'true' } 138 | 139 | it 'clicks the checkbox' do 140 | checkbox.uncheck 141 | expect(mouse_controller).to have_received(:click) 142 | end 143 | end 144 | 145 | context 'when checkbox is already unchecked' do 146 | let(:checked) { nil } 147 | 148 | it 'does not click the checkbox' do 149 | checkbox.uncheck 150 | expect(mouse_controller).not_to have_received(:click) 151 | end 152 | end 153 | 154 | context 'when unchecking for method chaining' do 155 | let(:checked) { 'true' } 156 | 157 | it 'returns self for method chaining' do 158 | expect(checkbox.uncheck).to eq(checkbox) 159 | end 160 | end 161 | end 162 | 163 | describe '#toggle' do 164 | subject(:checkbox) { described_class.new(selector, client) } 165 | 166 | before do 167 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 168 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'checkbox' }) 169 | mock_element_click(client, node_id: node_id) 170 | allow(mouse_controller).to receive(:click) 171 | end 172 | 173 | it 'clicks the checkbox' do 174 | checkbox.toggle 175 | expect(mouse_controller).to have_received(:click) 176 | end 177 | 178 | it 'returns self for method chaining' do 179 | expect(checkbox.toggle).to eq(checkbox) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /spec/chromate/elements/option_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Elements::Option do 6 | let(:value) { 'test-value' } 7 | let(:client) { instance_double(Chromate::Client) } 8 | let(:mouse_controller) { instance_double(Chromate::Hardwares::MouseController) } 9 | let(:keyboard_controller) { instance_double(Chromate::Hardwares::KeyboardController) } 10 | let(:node_id) { 123 } 11 | let(:object_id) { 'object-123' } 12 | let(:root_id) { 456 } 13 | 14 | let(:configuration) do 15 | instance_double(Chromate::Configuration, 16 | mouse_controller: mouse_controller, 17 | keyboard_controller: keyboard_controller) 18 | end 19 | 20 | before do 21 | allow(Chromate).to receive(:configuration).and_return(configuration) 22 | allow(mouse_controller).to receive(:set_element).and_return(mouse_controller) 23 | allow(keyboard_controller).to receive(:set_element).and_return(keyboard_controller) 24 | mock_default_element_responses(client, root_id: root_id, node_id: node_id, object_id: object_id) 25 | end 26 | 27 | describe '#initialize' do 28 | subject(:option) { described_class.new(value, client) } 29 | 30 | it 'sets the value' do 31 | expect(option.value).to eq(value) 32 | end 33 | 34 | it 'constructs the correct selector' do 35 | expect(option.selector).to eq("option[value='#{value}']") 36 | end 37 | end 38 | 39 | describe '#bounding_box' do 40 | subject(:option) { described_class.new(value, client) } 41 | 42 | let(:x) { 100 } 43 | let(:y) { 200 } 44 | let(:width) { 300 } 45 | let(:height) { 400 } 46 | 47 | before do 48 | mock_option_bounding_box(client, object_id: object_id, x: x, y: y, width: width, height: height) 49 | end 50 | 51 | it 'returns the bounding box with adjusted coordinates' do 52 | expect(option.bounding_box).to eq( 53 | 'content' => [x + 100, y + 100], 54 | 'width' => width, 55 | 'height' => height 56 | ) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/chromate/elements/radio_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Elements::Radio do 6 | let(:selector) { '#test-radio' } 7 | let(:client) { instance_double(Chromate::Client) } 8 | let(:mouse_controller) { instance_double(Chromate::Hardwares::MouseController) } 9 | let(:keyboard_controller) { instance_double(Chromate::Hardwares::KeyboardController) } 10 | let(:node_id) { 123 } 11 | let(:object_id) { 'object-123' } 12 | let(:root_id) { 456 } 13 | 14 | let(:configuration) do 15 | instance_double(Chromate::Configuration, 16 | mouse_controller: mouse_controller, 17 | keyboard_controller: keyboard_controller) 18 | end 19 | 20 | before do 21 | allow(Chromate).to receive(:configuration).and_return(configuration) 22 | allow(mouse_controller).to receive(:set_element).and_return(mouse_controller) 23 | allow(keyboard_controller).to receive(:set_element).and_return(keyboard_controller) 24 | mock_default_element_responses(client, root_id: root_id, node_id: node_id, object_id: object_id) 25 | end 26 | 27 | describe '#initialize' do 28 | context 'with valid radio element' do 29 | before do 30 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 31 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'radio' }) 32 | end 33 | 34 | it 'creates a new radio instance' do 35 | expect { described_class.new(selector, client) }.not_to raise_error 36 | end 37 | end 38 | 39 | context 'with invalid element' do 40 | before do 41 | mock_element_tag_name(client, object_id: object_id, tag_name: 'div') 42 | end 43 | 44 | it 'raises InvalidSelectorError' do 45 | expect { described_class.new(selector, client) } 46 | .to raise_error(Chromate::Element::InvalidSelectorError) 47 | end 48 | end 49 | end 50 | 51 | describe '#radio?' do 52 | subject(:radio) { described_class.new(selector, client) } 53 | 54 | before do 55 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 56 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'radio' }) 57 | end 58 | 59 | it 'returns true for radio elements' do 60 | expect(radio.radio?).to be true 61 | end 62 | end 63 | 64 | describe '#checked?' do 65 | subject(:radio) { described_class.new(selector, client) } 66 | 67 | before do 68 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 69 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'radio', 'checked' => checked }) 70 | end 71 | 72 | context 'when radio is checked' do 73 | let(:checked) { 'true' } 74 | 75 | it 'returns true' do 76 | expect(radio.checked?).to be true 77 | end 78 | end 79 | 80 | context 'when radio is unchecked' do 81 | let(:checked) { nil } 82 | 83 | it 'returns false' do 84 | expect(radio.checked?).to be false 85 | end 86 | end 87 | end 88 | 89 | describe '#check' do 90 | subject(:radio) { described_class.new(selector, client) } 91 | 92 | before do 93 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 94 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'radio', 'checked' => checked }) 95 | mock_element_click(client, node_id: node_id) 96 | end 97 | 98 | before do 99 | allow(mouse_controller).to receive(:click) 100 | end 101 | 102 | context 'when radio is already checked' do 103 | let(:checked) { 'true' } 104 | 105 | it 'does not click the radio' do 106 | radio.check 107 | expect(mouse_controller).not_to have_received(:click) 108 | end 109 | end 110 | 111 | context 'when radio is unchecked' do 112 | let(:checked) { nil } 113 | 114 | it 'clicks the radio' do 115 | radio.check 116 | expect(mouse_controller).to have_received(:click) 117 | end 118 | end 119 | 120 | context 'when checking for method chaining' do 121 | let(:checked) { nil } 122 | 123 | it 'returns self for method chaining' do 124 | expect(radio.check).to eq(radio) 125 | end 126 | end 127 | end 128 | 129 | describe '#uncheck' do 130 | subject(:radio) { described_class.new(selector, client) } 131 | 132 | before do 133 | mock_element_tag_name(client, object_id: object_id, tag_name: 'input') 134 | mock_element_attributes(client, node_id: node_id, attributes: { 'type' => 'radio', 'checked' => checked }) 135 | mock_element_click(client, node_id: node_id) 136 | end 137 | 138 | before do 139 | allow(mouse_controller).to receive(:click) 140 | end 141 | 142 | context 'when radio is checked' do 143 | let(:checked) { 'true' } 144 | 145 | it 'clicks the radio' do 146 | radio.uncheck 147 | expect(mouse_controller).to have_received(:click) 148 | end 149 | end 150 | 151 | context 'when radio is already unchecked' do 152 | let(:checked) { nil } 153 | 154 | it 'does not click the radio' do 155 | radio.uncheck 156 | expect(mouse_controller).not_to have_received(:click) 157 | end 158 | end 159 | 160 | context 'when unchecking for method chaining' do 161 | let(:checked) { 'true' } 162 | 163 | it 'returns self for method chaining' do 164 | expect(radio.uncheck).to eq(radio) 165 | end 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /spec/chromate/elements/select_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Elements::Select do 6 | let(:selector) { '#test-select' } 7 | let(:client) { instance_double(Chromate::Client) } 8 | let(:mouse_controller) { instance_double(Chromate::Hardwares::MouseController) } 9 | let(:keyboard_controller) { instance_double(Chromate::Hardwares::KeyboardController) } 10 | let(:node_id) { 123 } 11 | let(:object_id) { 'object-123' } 12 | let(:root_id) { 456 } 13 | 14 | let(:configuration) do 15 | instance_double(Chromate::Configuration, 16 | mouse_controller: mouse_controller, 17 | keyboard_controller: keyboard_controller, 18 | native_control: native_control) 19 | end 20 | 21 | let(:native_control) { false } 22 | 23 | before do 24 | allow(Chromate).to receive(:configuration).and_return(configuration) 25 | allow(mouse_controller).to receive(:set_element).and_return(mouse_controller) 26 | allow(keyboard_controller).to receive(:set_element).and_return(keyboard_controller) 27 | mock_default_element_responses(client, root_id: root_id, node_id: node_id, object_id: object_id) 28 | end 29 | 30 | subject(:select) { described_class.new(selector, client) } 31 | 32 | describe '#select_option' do 33 | let(:option_value) { 'test-option' } 34 | let(:option_node_id) { 789 } 35 | let(:option_object_id) { 'option-789' } 36 | 37 | before do 38 | mock_element_click(client, node_id: node_id) 39 | allow(mouse_controller).to receive(:click) 40 | 41 | # Mock pour l'option 42 | allow(client).to receive(:send_message) 43 | .with('DOM.querySelector', nodeId: root_id, selector: "option[value='#{option_value}']") 44 | .and_return({ 'nodeId' => option_node_id }) 45 | allow(client).to receive(:send_message) 46 | .with('DOM.resolveNode', nodeId: option_node_id) 47 | .and_return({ 'object' => { 'objectId' => option_object_id } }) 48 | mock_element_click(client, node_id: option_node_id) 49 | 50 | # Mock pour l'évaluation du script JavaScript 51 | allow(client).to receive(:send_message) 52 | .with('Runtime.callFunctionOn', hash_including( 53 | objectId: object_id, 54 | returnByValue: true 55 | )) 56 | .and_return({ 'result' => { 'value' => nil } }) 57 | 58 | # Mock pour le scrollIntoView 59 | allow(client).to receive(:send_message) 60 | .with('DOM.scrollIntoViewIfNeeded', nodeId: option_node_id) 61 | .and_return({}) 62 | end 63 | 64 | context 'when native_control is false' do 65 | let(:native_control) { false } 66 | 67 | before do 68 | mock_select_option(client, object_id: object_id, value: option_value) 69 | end 70 | 71 | it 'selects the option using JavaScript and clicks it' do 72 | select.select_option(option_value) 73 | expect(mouse_controller).to have_received(:click).twice # Once for select, once for option 74 | end 75 | end 76 | 77 | context 'when native_control is true' do 78 | let(:native_control) { true } 79 | 80 | it 'only clicks the select and option elements' do 81 | select.select_option(option_value) 82 | expect(mouse_controller).to have_received(:click).twice # Once for select, once for option 83 | end 84 | end 85 | 86 | it 'returns self for method chaining' do 87 | expect(select.select_option(option_value)).to eq(select) 88 | end 89 | end 90 | 91 | describe '#selected_value' do 92 | let(:selected_value) { 'selected-option' } 93 | 94 | before do 95 | mock_select_selected_value(client, object_id: object_id, value: selected_value) 96 | end 97 | 98 | it 'returns the currently selected value' do 99 | expect(select.selected_value).to eq(selected_value) 100 | end 101 | end 102 | 103 | describe '#selected_text' do 104 | let(:selected_text) { 'Selected Option Text' } 105 | 106 | before do 107 | mock_select_selected_text(client, object_id: object_id, text: selected_text) 108 | end 109 | 110 | it 'returns the text of the currently selected option' do 111 | expect(select.selected_text).to eq(selected_text) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/chromate/elements/tags_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate::Elements::Tags do 6 | let(:test_class) do 7 | Class.new do 8 | include Chromate::Elements::Tags 9 | 10 | attr_reader :client 11 | 12 | def initialize(client) 13 | @client = client 14 | end 15 | 16 | attr_reader :tag_name 17 | 18 | def attributes 19 | @attributes ||= {} 20 | end 21 | 22 | def tag!(name) 23 | @tag_name = name 24 | end 25 | 26 | def set_attribute(name, value) 27 | @attributes ||= {} 28 | @attributes[name] = value 29 | end 30 | end 31 | end 32 | 33 | let(:client) { instance_double(Chromate::Client) } 34 | subject(:element) { test_class.new(client) } 35 | 36 | describe '#select?' do 37 | context 'when element is a select' do 38 | before { element.tag!('select') } 39 | 40 | it 'returns true' do 41 | expect(element.select?).to be true 42 | end 43 | end 44 | 45 | context 'when element is not a select' do 46 | before { element.tag!('div') } 47 | 48 | it 'returns false' do 49 | expect(element.select?).to be false 50 | end 51 | end 52 | end 53 | 54 | describe '#option?' do 55 | context 'when element is an option' do 56 | before { element.tag!('option') } 57 | 58 | it 'returns true' do 59 | expect(element.option?).to be true 60 | end 61 | end 62 | 63 | context 'when element is not an option' do 64 | before { element.tag!('div') } 65 | 66 | it 'returns false' do 67 | expect(element.option?).to be false 68 | end 69 | end 70 | end 71 | 72 | describe '#radio?' do 73 | context 'when element is a radio input' do 74 | before do 75 | element.tag!('input') 76 | element.set_attribute('type', 'radio') 77 | end 78 | 79 | it 'returns true' do 80 | expect(element.radio?).to be true 81 | end 82 | end 83 | 84 | context 'when element is not a radio input' do 85 | before do 86 | element.tag!('input') 87 | element.set_attribute('type', 'text') 88 | end 89 | 90 | it 'returns false' do 91 | expect(element.radio?).to be false 92 | end 93 | end 94 | end 95 | 96 | describe '#checkbox?' do 97 | context 'when element is a checkbox input' do 98 | before do 99 | element.tag!('input') 100 | element.set_attribute('type', 'checkbox') 101 | end 102 | 103 | it 'returns true' do 104 | expect(element.checkbox?).to be true 105 | end 106 | end 107 | 108 | context 'when element is not a checkbox input' do 109 | before do 110 | element.tag!('input') 111 | element.set_attribute('type', 'text') 112 | end 113 | 114 | it 'returns false' do 115 | expect(element.checkbox?).to be false 116 | end 117 | end 118 | end 119 | 120 | describe '#base?' do 121 | context 'when element is a basic element' do 122 | before { element.tag!('div') } 123 | 124 | it 'returns true' do 125 | expect(element.base?).to be true 126 | end 127 | end 128 | 129 | %w[select option].each do |tag| 130 | context "when element is a #{tag}" do 131 | before { element.tag!(tag) } 132 | 133 | it 'returns false' do 134 | expect(element.base?).to be false 135 | end 136 | end 137 | end 138 | 139 | [%w[radio radio], %w[checkbox checkbox]].each do |type, name| 140 | context "when element is a #{name} input" do 141 | before do 142 | element.tag!('input') 143 | element.set_attribute('type', type) 144 | end 145 | 146 | it 'returns false' do 147 | expect(element.base?).to be false 148 | end 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/chromate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Chromate do 6 | it 'has a version number' do 7 | expect(Chromate::VERSION).not_to be nil 8 | end 9 | 10 | it 'can be configured' do 11 | Chromate.configure do |config| 12 | expect(config).to be_a Chromate::Configuration 13 | end 14 | end 15 | 16 | it 'has a configuration' do 17 | expect(Chromate.configuration).to be_a Chromate::Configuration 18 | end 19 | 20 | it 'can be configured with a block' do 21 | Chromate.configure do |config| 22 | config.user_data_dir = 'foo' 23 | end 24 | 25 | expect(Chromate.configuration.user_data_dir).to eq 'foo' 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'chromate' 4 | 5 | Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |file| require file } 6 | 7 | RSpec.configure do |config| 8 | include Support::Server 9 | include Support::ClientHelper 10 | include Support::CleanupHelper 11 | 12 | config.example_status_persistence_file_path = '.rspec_status' 13 | config.disable_monkey_patching! 14 | 15 | config.include Support::Server 16 | config.include Support::Modes 17 | 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | 22 | config.before(:suite) do |_example| 23 | Chromate::CLogger.log('Starting test servers') 24 | start_servers 25 | end 26 | 27 | config.after(:suite) do |_example| 28 | Chromate::CLogger.log('Stopping test servers') 29 | stop_servers 30 | end 31 | 32 | config.after(:each) do 33 | reset_client_mocks 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/support/cleanup_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Support 4 | module CleanupHelper 5 | def reset_client_mocks 6 | RSpec::Mocks.space.proxy_for(Chromate::Client).reset if defined?(Chromate::Client) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/cleanup_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Client mocks cleanup' do 6 | let(:client) { instance_double(Chromate::Client) } 7 | 8 | it 'resets mocks between tests' do 9 | allow(client).to receive(:send_message).with('test').and_return('test') 10 | expect(client.send_message('test')).to eq('test') 11 | end 12 | 13 | it 'starts with fresh mocks' do 14 | expect { client.send_message('test') }.to raise_error(RSpec::Mocks::MockExpectationError) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/client_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Support 4 | module ClientHelper 5 | def mock_default_element_responses(client, root_id: 456, node_id: 123, object_id: 'object-123') 6 | # Mock pour le document 7 | allow(client).to receive(:send_message) 8 | .with('DOM.getDocument') 9 | .and_return({ 'root' => { 'nodeId' => root_id } }) 10 | 11 | # Mock pour la recherche d'élément 12 | allow(client).to receive(:send_message) 13 | .with('DOM.querySelector', any_args) 14 | .and_return({ 'nodeId' => node_id }) 15 | allow(client).to receive(:send_message) 16 | .with('DOM.resolveNode', any_args) 17 | .and_return({ 'object' => { 'objectId' => object_id } }) 18 | 19 | # Mock pour le shadow DOM 20 | allow(client).to receive(:send_message) 21 | .with('DOM.describeNode', any_args) 22 | .and_return({ 'node' => { 'shadowRoots' => [] } }) 23 | allow(client).to receive(:send_message) 24 | .with('DOM.querySelectorAll', any_args) 25 | .and_return({ 'nodeIds' => [] }) 26 | end 27 | 28 | def mock_element_not_found(client, root_id:, selector:) 29 | allow(client).to receive(:send_message).with(any_args).and_return({}) 30 | allow(client).to receive(:send_message) 31 | .with('DOM.getDocument') 32 | .and_return({ 'root' => { 'nodeId' => root_id } }) 33 | allow(client).to receive(:send_message) 34 | .with('DOM.querySelector', nodeId: root_id, selector: selector) 35 | .and_return({ 'nodeId' => nil }) 36 | end 37 | 38 | def mock_element_text(client, object_id:, text:) 39 | allow(client).to receive(:send_message) 40 | .with('Runtime.callFunctionOn', 41 | hash_including(functionDeclaration: 'function() { return this.innerText; }', 42 | objectId: object_id, 43 | returnByValue: true)) 44 | .and_return({ 'result' => { 'value' => text } }) 45 | end 46 | 47 | def mock_element_value(client, object_id:, value:) 48 | allow(client).to receive(:send_message) 49 | .with('Runtime.callFunctionOn', 50 | hash_including(functionDeclaration: 'function() { return this.value; }', 51 | objectId: object_id, 52 | returnByValue: true)) 53 | .and_return({ 'result' => { 'value' => value } }) 54 | end 55 | 56 | def mock_element_focus(client, node_id:) 57 | allow(client).to receive(:send_message).with('DOM.focus', nodeId: node_id) 58 | end 59 | 60 | def mock_element_tag_name(client, object_id:, tag_name:) 61 | allow(client).to receive(:send_message) 62 | .with('Runtime.callFunctionOn', 63 | hash_including(functionDeclaration: 'function() { return this.tagName.toLowerCase(); }', 64 | objectId: object_id, 65 | returnByValue: true)) 66 | .and_return({ 'result' => { 'value' => tag_name } }) 67 | end 68 | 69 | def mock_element_click(client, node_id:) 70 | allow(client).to receive(:send_message) 71 | .with('DOM.focus', nodeId: node_id) 72 | allow(client).to receive(:send_message) 73 | .with('DOM.scrollIntoViewIfNeeded', nodeId: node_id) 74 | end 75 | 76 | def mock_element_evaluate_script(client, object_id:, script:, result:) 77 | allow(client).to receive(:send_message) 78 | .with('Runtime.callFunctionOn', 79 | hash_including(functionDeclaration: script, 80 | objectId: object_id, 81 | returnByValue: true)) 82 | .and_return({ 'result' => { 'value' => result } }) 83 | end 84 | 85 | def mock_option_bounding_box(client, object_id:, x:, y:, width:, height:) 86 | script = <<~JAVASCRIPT 87 | function() { 88 | const select = this.closest('select'); 89 | const rect = select.getBoundingClientRect(); 90 | return { 91 | x: rect.x, 92 | y: rect.y, 93 | width: rect.width, 94 | height: rect.height 95 | }; 96 | } 97 | JAVASCRIPT 98 | 99 | mock_element_evaluate_script(client, object_id: object_id, script: script, 100 | result: { 'x' => x, 'y' => y, 'width' => width, 'height' => height }) 101 | end 102 | 103 | def mock_select_option(client, object_id:, value:) 104 | script = <<~JAVASCRIPT 105 | function() { 106 | this.focus(); 107 | this.dispatchEvent(new MouseEvent('mousedown')); 108 | 109 | const options = Array.from(this.options); 110 | const option = options.find(opt => 111 | opt.value === arguments[0] || opt.textContent.trim() === arguments[0] 112 | ); 113 | 114 | if (!option) { 115 | throw new Error(`Option '${arguments[0]}' not found in select`); 116 | } 117 | 118 | this.value = option.value; 119 | 120 | this.dispatchEvent(new Event('change', { bubbles: true })); 121 | this.dispatchEvent(new Event('input', { bubbles: true })); 122 | 123 | this.blur(); 124 | } 125 | JAVASCRIPT 126 | 127 | allow(client).to receive(:send_message) 128 | .with('Runtime.callFunctionOn', 129 | hash_including(functionDeclaration: script, 130 | objectId: object_id, 131 | arguments: [{ value: value }], 132 | returnByValue: true)) 133 | end 134 | 135 | def mock_select_selected_value(client, object_id:, value:) 136 | allow(client).to receive(:send_message) 137 | .with('Runtime.callFunctionOn', 138 | hash_including(functionDeclaration: 'function() { return this.value; }', 139 | objectId: object_id, 140 | returnByValue: true)) 141 | .and_return({ 'result' => { 'value' => value } }) 142 | end 143 | 144 | def mock_select_selected_text(client, object_id:, text:) 145 | script = 'function() { 146 | const option = this.options[this.selectedIndex]; 147 | return option ? option.textContent.trim() : null; 148 | }' 149 | 150 | allow(client).to receive(:send_message) 151 | .with('Runtime.callFunctionOn', 152 | hash_including(functionDeclaration: script, 153 | objectId: object_id, 154 | returnByValue: true)) 155 | .and_return({ 'result' => { 'value' => text } }) 156 | end 157 | 158 | def mock_element_attributes(client, node_id:, attributes:) 159 | allow(client).to receive(:send_message) 160 | .with('DOM.getAttributes', nodeId: node_id) 161 | .and_return({ 'attributes' => attributes.to_a.flatten }) 162 | end 163 | end 164 | end -------------------------------------------------------------------------------- /spec/support/modes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'webrick' 4 | require 'chromate/c_logger' 5 | 6 | module Support 7 | module Modes 8 | USER_DATA_DIR = File.expand_path('/tmp') 9 | 10 | def browser_args 11 | FileUtils.rm_rf(USER_DATA_DIR) 12 | FileUtils.mkdir_p(USER_DATA_DIR) 13 | 14 | case ENV.fetch('CHROMATE_MODE', nil) 15 | when 'local-x' 16 | { 17 | headless: false, 18 | xfvb: false, 19 | native_control: false, 20 | record: false, 21 | user_data_dir: USER_DATA_DIR 22 | } 23 | when 'docker-xvfb' 24 | { 25 | headless: false, 26 | xfvb: true, 27 | native_control: true, 28 | record: "spec/video-records/#{example_name}.mp4", 29 | user_data_dir: USER_DATA_DIR 30 | } 31 | when 'bot-browser' 32 | require 'bot_browser' 33 | BotBrowser.install unless BotBrowser.installed? 34 | BotBrowser.load 35 | { 36 | headless: false, 37 | xfvb: false, 38 | native_control: false, 39 | user_data_dir: USER_DATA_DIR 40 | } 41 | when 'debug' 42 | { 43 | headless: false, 44 | xfvb: false, 45 | native_control: false, 46 | user_data_dir: USER_DATA_DIR 47 | } 48 | else 49 | { 50 | headless: true, 51 | xfvb: false, 52 | native_control: false, 53 | user_data_dir: USER_DATA_DIR 54 | } 55 | end 56 | end 57 | 58 | def example_name 59 | if defined?(RSpec.current_example) 60 | RSpec.current_example.full_description.downcase.gsub(/\s+/, '-').gsub(/[^a-z0-9-]/, '') 61 | else 62 | Time.now.to_i.to_s 63 | end 64 | end 65 | end 66 | end 67 | 68 | # docker run -it --rm -v $(pwd):/app --env CHROMATE_MODE=docker-xvfb chromate:latest bundle exec rspec 69 | -------------------------------------------------------------------------------- /spec/support/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'webrick' 4 | require 'chromate/c_logger' 5 | 6 | module Support 7 | module Server 8 | def start_servers 9 | directories = Dir[File.join(File.dirname(__FILE__), '../apps/*')].select { |entry| File.directory?(entry) } 10 | ports = (12_500..12_800).to_a 11 | @@servers = [] # rubocop:disable Style/ClassVars 12 | @@server_urls = {} # rubocop:disable Style/ClassVars 13 | 14 | directories.each_with_index do |directory, index| 15 | port = ports[index] 16 | next unless port 17 | 18 | server = WEBrick::HTTPServer.new( 19 | Port: port, 20 | DocumentRoot: directory, 21 | Logger: WEBrick::Log.new(nil, WEBrick::Log::ERROR), 22 | AccessLog: [] 23 | ) 24 | 25 | thread = Thread.new { server.start } 26 | 27 | @@servers << { server: server, thread: thread } 28 | @@server_urls[File.basename(directory)] = "http://127.0.0.1:#{port}" 29 | Chromate::CLogger.log("Server started for #{directory} on port #{port}") 30 | end 31 | 32 | # Stop servers when the test suite is interrupted 33 | trap('INT') { properly_exit } 34 | # Stop servers when the test suite is stopped 35 | trap('TERM') { properly_exit } 36 | 37 | true 38 | end 39 | 40 | def servers 41 | @@servers 42 | end 43 | 44 | def server_urls 45 | @@server_urls 46 | end 47 | 48 | def properly_exit 49 | stop_servers 50 | rescue StandardError => e 51 | Chromate::CLogger.log("Error stopping servers: #{e.message}") 52 | exit(1) 53 | end 54 | 55 | def stop_servers 56 | @@servers.each do |entry| 57 | entry[:server].shutdown 58 | entry[:thread].join if entry[:thread].alive? 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/video-records/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eth3rnit3/chromate/0220b61a9af9e70b3d064c224ceac3173a1f6b3a/spec/video-records/.keep --------------------------------------------------------------------------------