├── .github ├── stale.yml └── workflows │ ├── deploy.yml │ └── rspec.yml ├── .gitignore ├── .rspec ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── capybara-playwright.gemspec ├── lib ├── capybara-playwright-driver.rb └── capybara │ ├── playwright.rb │ └── playwright │ ├── browser.rb │ ├── browser_options.rb │ ├── browser_runner.rb │ ├── dialog_event_handler.rb │ ├── driver.rb │ ├── driver_extension.rb │ ├── node.rb │ ├── page.rb │ ├── page_options.rb │ ├── shadow_root_node.rb │ ├── tmpdir_owner.rb │ └── version.rb └── spec ├── capybara ├── playwright │ └── dialog_event_handler_spec.rb └── playwright_spec.rb ├── feature ├── assertion_spec.rb ├── attach_file_spec.rb ├── example_spec.rb ├── timeout_spec.rb └── tracing_spec.rb └── spec_helper.rb /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 7 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 3 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - security 8 | # Label to use when marking an issue as stale 9 | staleLabel: inactive 10 | # Comment to post when marking an issue as stale. Set to `false` to disable 11 | markComment: > 12 | This issue has been automatically marked as stale because it has not had 13 | recent activity. It will be closed if no further activity occurs. Thank you 14 | for your contributions. 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: false 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | push_to_rubygems: 10 | name: Push to RubyGems 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set RELEASE_TAG 14 | run: echo "RELEASE_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 15 | - uses: actions/checkout@v4 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 3.3 19 | bundler-cache: true 20 | - name: Check Capybara::Playwright::VERSION 21 | run: bundle exec ruby -e 'raise "invalid Capybara::Playwright::VERSION" unless Capybara::Playwright::VERSION == ENV["RELEASE_TAG"]' 22 | - run: rake build 23 | - name: setup API key 24 | run: | 25 | mkdir -p ~/.gem/ 26 | echo "---" > ~/.gem/credentials 27 | echo ":rubygems_api_key: $RUBYGEMS_API_KEY" >> ~/.gem/credentials 28 | chmod 600 ~/.gem/credentials 29 | env: 30 | RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} 31 | - run: gem push pkg/capybara-playwright-driver-$RELEASE_TAG.gem 32 | -------------------------------------------------------------------------------- /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | name: RSpec 2 | on: [pull_request] 3 | jobs: 4 | example_spec_legacy: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby_version: 9 | - 2.4.10 10 | - 2.5.9 11 | - 2.6.10 12 | - 2.7.8 13 | name: (${{ matrix.ruby_version }}) Example 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby_version }} 21 | bundler-cache: true 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | - name: setup playwright via npm install 26 | run: | 27 | export PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') 28 | npm install playwright@${PLAYWRIGHT_CLI_VERSION} || npm install playwright@next 29 | ./node_modules/.bin/playwright install --with-deps 30 | - run: bundle exec rspec spec/feature/example_spec.rb 31 | env: 32 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 33 | timeout-minutes: 3 34 | - run: bundle exec rspec spec/feature/ --exclude-pattern "spec/feature/example_spec.rb" 35 | env: 36 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 37 | timeout-minutes: 3 38 | 39 | example_spec: 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | ruby_version: 44 | - "3.0" 45 | - "3.1" 46 | - "3.2" 47 | - "3.3" 48 | - "3.4" 49 | name: (${{ matrix.ruby_version }}) Example 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Check out code 53 | uses: actions/checkout@v4 54 | - uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: ${{ matrix.ruby_version }} 57 | bundler-cache: true 58 | - uses: actions/setup-node@v4 59 | with: 60 | node-version: lts/* 61 | - name: setup playwright via npm install 62 | run: | 63 | export PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') 64 | npm install playwright@${PLAYWRIGHT_CLI_VERSION} || npm install playwright@next 65 | ./node_modules/.bin/playwright install --with-deps 66 | - run: bundle exec rspec spec/feature/example_spec.rb 67 | env: 68 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 69 | timeout-minutes: 3 70 | - run: bundle exec rspec spec/feature/ --exclude-pattern "spec/feature/example_spec.rb" 71 | env: 72 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 73 | timeout-minutes: 3 74 | 75 | playwright_driver_spec: 76 | needs: example_spec 77 | strategy: 78 | fail-fast: false 79 | matrix: 80 | browser: [chromium, webkit] 81 | name: (${{ matrix.browser }}) Playwright Driver 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Check out code 85 | uses: actions/checkout@v4 86 | - uses: ruby/setup-ruby@v1 87 | with: 88 | ruby-version: 3.3 89 | bundler-cache: true 90 | - uses: actions/setup-node@v4 91 | with: 92 | node-version: lts/* 93 | - name: setup playwright via npm install 94 | run: | 95 | export PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') 96 | npm install playwright@${PLAYWRIGHT_CLI_VERSION} || npm install playwright@next 97 | ./node_modules/.bin/playwright install --with-deps 98 | - run: bundle exec rspec spec/capybara/ 99 | env: 100 | BROWSER: ${{ matrix.browser }} 101 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 102 | timeout-minutes: 25 103 | 104 | playwright_driver_spec_firefox: 105 | needs: example_spec 106 | name: (firefox) Playwright Driver 107 | runs-on: ubuntu-latest 108 | steps: 109 | - name: Check out code 110 | uses: actions/checkout@v4 111 | - name: setup Allure 112 | run: | 113 | wget https://github.com/allure-framework/allure2/releases/download/2.14.0/allure_2.14.0-1_all.deb 114 | sudo dpkg -i allure_2.14.0-1_all.deb 115 | rm allure_2.14.0-1_all.deb 116 | - uses: ruby/setup-ruby@v1 117 | with: 118 | ruby-version: 3.3 119 | bundler-cache: true 120 | - uses: actions/setup-node@v4 121 | with: 122 | node-version: lts/* 123 | - name: setup playwright via npm install 124 | run: | 125 | export PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') 126 | npm install playwright@${PLAYWRIGHT_CLI_VERSION} || npm install playwright@next 127 | ./node_modules/.bin/playwright install --with-deps 128 | - run: bundle exec rspec spec/capybara/ --format AllureRspecFormatter --format documentation --failure-exit-code 0 129 | env: 130 | BROWSER: firefox 131 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 132 | timeout-minutes: 45 133 | - run: bundle exec rspec spec/capybara/ --format AllureRspecFormatter --format documentation --only-failures 134 | env: 135 | BROWSER: firefox 136 | DEBUG: 1 137 | PLAYWRIGHT_CLI_EXECUTABLE_PATH: ./node_modules/.bin/playwright 138 | timeout-minutes: 15 139 | - run: allure generate reports/allure-results 140 | - uses: actions/upload-artifact@v4 141 | with: 142 | name: allure-report 143 | path: allure-report 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rbenv 11 | .ruby-version 12 | 13 | # direnv 14 | .envrc 15 | 16 | /.vscode 17 | /Gemfile.lock 18 | /vendor/bundle 19 | .DS_Store 20 | 21 | # rspec failure tracking 22 | .rspec_status 23 | 24 | # RubyMine 25 | /.idea/ 26 | /.rakeTasks 27 | 28 | /package.json 29 | /package-lock.json 30 | /node_modules 31 | 32 | /save_path_tmp 33 | /reports 34 | /allure-report 35 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at iwaki@i3-systems.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in capybara-playwright.gemspec 8 | gemspec 9 | 10 | gem 'allure-rspec' 11 | gem 'bundler' 12 | gem 'launchy', '>= 2.0.4' 13 | gem 'pry-byebug' 14 | gem 'rack-test_server' 15 | gem 'rake', '~> 13.0.3' 16 | gem 'rspec', '~> 3.11.0' 17 | gem 'rubocop-rspec' 18 | gem 'sinatra', '>= 1.4.0' 19 | gem 'webrick' 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 YusukeIwaki 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 | [![Gem Version](https://badge.fury.io/rb/capybara-playwright-driver.svg)](https://badge.fury.io/rb/capybara-playwright-driver) 2 | 3 | # 🎭 Playwright driver for Capybara 4 | 5 | Make it easy to introduce Playwright into your Rails application. 6 | 7 | ```ruby 8 | gem 'capybara-playwright-driver' 9 | ``` 10 | 11 | **NOTE**: If you want to use Playwright-native features (such as auto-waiting, various type of locators, ...), [consider using playwright-ruby-client directly](https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration_with_null_driver). 12 | 13 | ## Examples 14 | 15 | ```ruby 16 | require 'capybara-playwright-driver' 17 | 18 | # setup 19 | Capybara.register_driver(:playwright) do |app| 20 | Capybara::Playwright::Driver.new(app, browser_type: :firefox, headless: false) 21 | end 22 | Capybara.default_max_wait_time = 15 23 | Capybara.default_driver = :playwright 24 | Capybara.save_path = 'tmp/capybara' 25 | 26 | # run 27 | Capybara.app_host = 'https://github.com' 28 | visit '/' 29 | first('div.search-input-container').click 30 | fill_in('query-builder-test', with: 'Capybara') 31 | 32 | ## [REMARK] We can use Playwright-native selector and action, instead of Capybara DSL. 33 | # first('[aria-label="Capybara, Search all of GitHub"]').click 34 | page.driver.with_playwright_page do |page| 35 | page.get_by_label('Capybara, Search all of GitHub').click 36 | end 37 | 38 | all('[data-testid="results-list"] h3').each do |li| 39 | #puts "#{li.all('a').first.text} by Capybara" 40 | puts "#{li.with_playwright_element_handle { |handle| handle.text_content }} by Playwright" 41 | end 42 | ``` 43 | 44 | Refer the [documentation](https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration) for more detailed configuration. 45 | 46 | ## License 47 | 48 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 49 | 50 | ## Code of Conduct 51 | 52 | Everyone interacting in the Capybara::Playwright project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/capybara-playwright/blob/master/CODE_OF_CONDUCT.md). 53 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'capybara/playwright' 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 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /capybara-playwright.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'capybara/playwright/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'capybara-playwright-driver' 9 | spec.version = Capybara::Playwright::VERSION 10 | 11 | spec.authors = ['YusukeIwaki'] 12 | spec.email = ['q7w8e9w8q7w8e9@yahoo.co.jp'] 13 | 14 | spec.summary = 'Playwright driver for Capybara' 15 | spec.homepage = 'https://github.com/YusukeIwaki/capybara-playwright-driver' 16 | spec.license = 'MIT' 17 | 18 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 19 | `git ls-files -z`.split("\x0").reject do |f| 20 | f.match(%r{^(test|spec|features)/}) || f.include?('.git') 21 | end 22 | end 23 | spec.bindir = 'exe' 24 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 25 | spec.require_paths = ['lib'] 26 | 27 | spec.required_ruby_version = '>= 2.4' 28 | spec.add_dependency 'addressable' 29 | spec.add_dependency 'capybara' 30 | spec.add_dependency 'playwright-ruby-client', '>= 1.16.0' 31 | end 32 | -------------------------------------------------------------------------------- /lib/capybara-playwright-driver.rb: -------------------------------------------------------------------------------- 1 | # just an alias. 2 | require 'capybara/playwright' 3 | -------------------------------------------------------------------------------- /lib/capybara/playwright.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'capybara' 4 | require 'playwright' 5 | 6 | require 'capybara/playwright/browser' 7 | require 'capybara/playwright/browser_runner' 8 | require 'capybara/playwright/browser_options' 9 | require 'capybara/playwright/dialog_event_handler' 10 | require 'capybara/playwright/driver' 11 | require 'capybara/playwright/node' 12 | require 'capybara/playwright/page' 13 | require 'capybara/playwright/page_options' 14 | require 'capybara/playwright/shadow_root_node' 15 | require 'capybara/playwright/version' 16 | -------------------------------------------------------------------------------- /lib/capybara/playwright/browser.rb: -------------------------------------------------------------------------------- 1 | require 'addressable/uri' 2 | require_relative './tmpdir_owner' 3 | 4 | module Capybara 5 | module Playwright 6 | # Responsibility of this class is: 7 | # - Handling Capybara::Driver commands. 8 | # - Managing Playwright browser contexts and pages. 9 | # 10 | # Note that this class doesn't manage Playwright::Browser. 11 | # We should not use Playwright::Browser#close in this class. 12 | class Browser 13 | include TmpdirOwner 14 | extend Forwardable 15 | 16 | class NoSuchWindowError < StandardError ; end 17 | 18 | def initialize(driver:, internal_logger:, playwright_browser:, page_options:, record_video: false, callback_on_save_trace: nil, default_timeout: nil, default_navigation_timeout: nil) 19 | @driver = driver 20 | @internal_logger = internal_logger 21 | @playwright_browser = playwright_browser 22 | @page_options = page_options 23 | if record_video 24 | @page_options[:record_video_dir] ||= tmpdir 25 | end 26 | @callback_on_save_trace = callback_on_save_trace 27 | @default_timeout = default_timeout 28 | @default_navigation_timeout = default_navigation_timeout 29 | @playwright_page = create_page(create_browser_context) 30 | end 31 | 32 | private def create_browser_context 33 | @playwright_browser.new_context(**@page_options).tap do |browser_context| 34 | browser_context.default_timeout = @default_timeout if @default_timeout 35 | browser_context.default_navigation_timeout = @default_navigation_timeout if @default_navigation_timeout 36 | browser_context.on('page', ->(page) { 37 | unless @playwright_page 38 | @playwright_page = page 39 | end 40 | page.send(:_update_internal_logger, @internal_logger) 41 | }) 42 | if @callback_on_save_trace 43 | browser_context.tracing.start(screenshots: true, snapshots: true) 44 | end 45 | end 46 | end 47 | 48 | private def create_page(browser_context) 49 | browser_context.new_page.tap do |page| 50 | page.on('close', -> { 51 | if @playwright_page 52 | @playwright_page = nil 53 | end 54 | }) 55 | end 56 | end 57 | 58 | def clear_browser_contexts 59 | if @callback_on_save_trace 60 | @playwright_browser.contexts.each do |browser_context| 61 | filename = SecureRandom.hex(8) 62 | zip_path = File.join(tmpdir, "#{filename}.zip") 63 | browser_context.tracing.stop(path: zip_path) 64 | @callback_on_save_trace.call(zip_path) 65 | end 66 | end 67 | @playwright_browser.contexts.each(&:close) 68 | end 69 | 70 | def current_url 71 | assert_page_alive { 72 | @playwright_page.url 73 | } 74 | end 75 | 76 | def visit(path) 77 | assert_page_alive { 78 | url = 79 | if Capybara.app_host 80 | Addressable::URI.parse(Capybara.app_host) + path 81 | elsif Capybara.default_host 82 | Addressable::URI.parse(Capybara.default_host) + path 83 | else 84 | path 85 | end 86 | 87 | @playwright_page.capybara_current_frame.goto(url) 88 | } 89 | end 90 | 91 | def refresh 92 | assert_page_alive { 93 | @playwright_page.capybara_current_frame.evaluate('() => { location.reload(true) }') 94 | } 95 | end 96 | 97 | def find_xpath(query, **options) 98 | assert_page_alive { 99 | @playwright_page.capybara_current_frame.query_selector_all("xpath=#{query}").map do |el| 100 | Node.new(@driver, @internal_logger, @playwright_page, el) 101 | end 102 | } 103 | end 104 | 105 | def find_css(query, **options) 106 | assert_page_alive { 107 | @playwright_page.capybara_current_frame.query_selector_all(query).map do |el| 108 | Node.new(@driver, @internal_logger, @playwright_page, el) 109 | end 110 | } 111 | end 112 | 113 | def response_headers 114 | assert_page_alive { 115 | @playwright_page.capybara_response_headers 116 | } 117 | end 118 | 119 | def status_code 120 | assert_page_alive { 121 | @playwright_page.capybara_status_code 122 | } 123 | end 124 | 125 | def html 126 | assert_page_alive { 127 | js = <<~JAVASCRIPT 128 | () => { 129 | let html = ''; 130 | if (document.doctype) html += new XMLSerializer().serializeToString(document.doctype); 131 | if (document.documentElement) html += document.documentElement.outerHTML; 132 | return html; 133 | } 134 | JAVASCRIPT 135 | @playwright_page.capybara_current_frame.evaluate(js) 136 | } 137 | end 138 | 139 | def title 140 | assert_page_alive { 141 | @playwright_page.title 142 | } 143 | end 144 | 145 | def go_back 146 | assert_page_alive { 147 | @playwright_page.go_back 148 | } 149 | end 150 | 151 | def go_forward 152 | assert_page_alive { 153 | @playwright_page.go_forward 154 | } 155 | end 156 | 157 | def execute_script(script, *args) 158 | assert_page_alive { 159 | @playwright_page.capybara_current_frame.evaluate("function (arguments) { #{script} }", arg: unwrap_node(args)) 160 | } 161 | nil 162 | end 163 | 164 | def evaluate_script(script, *args) 165 | assert_page_alive { 166 | result = @playwright_page.capybara_current_frame.evaluate_handle("function (arguments) { return #{script} }", arg: unwrap_node(args)) 167 | wrap_node(result) 168 | } 169 | end 170 | 171 | def evaluate_async_script(script, *args) 172 | assert_page_alive { 173 | js = <<~JAVASCRIPT 174 | function(_arguments){ 175 | let args = Array.prototype.slice.call(_arguments); 176 | return new Promise((resolve, reject) => { 177 | args.push(resolve); 178 | (function(){ #{script} }).apply(this, args); 179 | }); 180 | } 181 | JAVASCRIPT 182 | result = @playwright_page.capybara_current_frame.evaluate_handle(js, arg: unwrap_node(args)) 183 | wrap_node(result) 184 | } 185 | end 186 | 187 | def active_element 188 | el = @playwright_page.capybara_current_frame.evaluate_handle('() => document.activeElement') 189 | if el 190 | Node.new(@driver, @internal_logger, @playwright_page, el) 191 | else 192 | nil 193 | end 194 | end 195 | 196 | # Not used by Capybara::Session. 197 | # Intended to be directly called by user. 198 | def video_path 199 | return nil if !@playwright_page || @playwright_page.closed? 200 | 201 | @playwright_page.video&.path 202 | end 203 | 204 | # Not used by Capybara::Session. 205 | # Intended to be directly called by user. 206 | def raw_screenshot(**options) 207 | return nil if !@playwright_page || @playwright_page.closed? 208 | 209 | @playwright_page.screenshot(**options) 210 | end 211 | 212 | def save_screenshot(path, **options) 213 | assert_page_alive { 214 | @playwright_page.screenshot(path: path) 215 | } 216 | end 217 | 218 | def send_keys(*args) 219 | Node::SendKeys.new(@playwright_page.keyboard, args).execute 220 | end 221 | 222 | def switch_to_frame(frame) 223 | assert_page_alive { 224 | case frame 225 | when :top 226 | @playwright_page.capybara_reset_frames 227 | when :parent 228 | @playwright_page.capybara_pop_frame 229 | else 230 | playwright_frame = frame.native.content_frame 231 | raise ArgumentError.new("Not a frame element: #{frame}") unless playwright_frame 232 | @playwright_page.capybara_push_frame(playwright_frame) 233 | end 234 | } 235 | end 236 | 237 | # Capybara doesn't retry at this case since it doesn't use `synchronize { ... } for driver/browser methods.` 238 | # We have to retry ourselves. 239 | private def assert_page_alive(retry_count: 5, &block) 240 | if !@playwright_page || @playwright_page.closed? 241 | raise NoSuchWindowError 242 | end 243 | 244 | if retry_count <= 0 245 | return block.call 246 | end 247 | 248 | begin 249 | return block.call 250 | rescue ::Playwright::Error => err 251 | case err.message 252 | when /Element is not attached to the DOM/, 253 | /Execution context was destroyed, most likely because of a navigation/, 254 | /Cannot find context with specified id/, 255 | /Unable to adopt element handle from a different document/ 256 | # ignore error for retry 257 | @internal_logger.warn(err.message) 258 | else 259 | raise 260 | end 261 | end 262 | 263 | assert_page_alive(retry_count: retry_count - 1, &block) 264 | end 265 | 266 | private def pages 267 | @playwright_browser.contexts.flat_map(&:pages) 268 | end 269 | 270 | def window_handles 271 | pages.map(&:guid) 272 | end 273 | 274 | def current_window_handle 275 | @playwright_page&.guid 276 | end 277 | 278 | def open_new_window(kind = :tab) 279 | browser_context = 280 | if kind == :tab 281 | @playwright_page&.context || create_browser_context 282 | else 283 | create_browser_context 284 | end 285 | 286 | create_page(browser_context) 287 | end 288 | 289 | private def on_window(handle, &block) 290 | page = pages.find { |page| page.guid == handle } 291 | if page 292 | block.call(page) 293 | else 294 | raise NoSuchWindowError 295 | end 296 | end 297 | 298 | def switch_to_window(handle) 299 | if @playwright_page&.guid != handle 300 | on_window(handle) do |page| 301 | @playwright_page = page.tap(&:bring_to_front) 302 | end 303 | end 304 | end 305 | 306 | def close_window(handle) 307 | on_window(handle) do |page| 308 | page.close 309 | 310 | if @playwright_page&.guid == handle 311 | @playwright_page = nil 312 | end 313 | end 314 | end 315 | 316 | def window_size(handle) 317 | on_window(handle) do |page| 318 | page.evaluate('() => [window.innerWidth, window.innerHeight]') 319 | end 320 | end 321 | 322 | def resize_window_to(handle, width, height) 323 | on_window(handle) do |page| 324 | page.viewport_size = { width: width, height: height } 325 | end 326 | end 327 | 328 | def maximize_window(handle) 329 | @internal_logger.warn("maximize_window is not supported in Playwright driver") 330 | # incomplete in Playwright 331 | # ref: https://github.com/twalpole/apparition/blob/11aca464b38b77585191b7e302be2e062bdd369d/lib/capybara/apparition/page.rb#L346 332 | on_window(handle) do |page| 333 | screen_size = page.evaluate('() => ({ width: window.screen.width, height: window.screen.height})') 334 | page.viewport_size = screen_size 335 | end 336 | end 337 | 338 | def fullscreen_window(handle) 339 | @internal_logger.warn("fullscreen_window is not supported in Playwright driver") 340 | # incomplete in Playwright 341 | # ref: https://github.com/twalpole/apparition/blob/11aca464b38b77585191b7e302be2e062bdd369d/lib/capybara/apparition/page.rb#L341 342 | on_window(handle) do |page| 343 | page.evaluate('() => document.body.requestFullscreen()') 344 | end 345 | end 346 | 347 | def accept_modal(dialog_type, **options, &block) 348 | assert_page_alive { 349 | @playwright_page.capybara_accept_modal(dialog_type, **options, &block) 350 | } 351 | end 352 | 353 | def dismiss_modal(dialog_type, **options, &block) 354 | assert_page_alive { 355 | @playwright_page.capybara_dismiss_modal(dialog_type, **options, &block) 356 | } 357 | end 358 | 359 | private def unwrap_node(args) 360 | args.map do |arg| 361 | if arg.is_a?(Node) 362 | arg.send(:element) 363 | else 364 | arg 365 | end 366 | end 367 | end 368 | 369 | private def wrap_node(arg) 370 | case arg 371 | when Array 372 | arg.map do |item| 373 | wrap_node(item) 374 | end 375 | when Hash 376 | arg.map do |key, value| 377 | [key, wrap_node(value)] 378 | end.to_h 379 | when ::Playwright::ElementHandle 380 | Node.new(@driver, @internal_logger, @playwright_page, arg) 381 | when ::Playwright::JSHandle 382 | obj_type, is_array = arg.evaluate('obj => [typeof obj, Array.isArray(obj)]') 383 | if obj_type == 'object' 384 | if is_array 385 | # Firefox often include 'toJSON' into properties. 386 | # https://github.com/microsoft/playwright/issues/7015 387 | # 388 | # Get rid of non-numeric entries. 389 | arg.properties.select { |key, _| key.to_i.to_s == key.to_s }.map do |_, value| 390 | wrap_node(value) 391 | end 392 | else 393 | arg.properties.map do |key, value| 394 | [key, wrap_node(value)] 395 | end.to_h 396 | end 397 | else 398 | arg.json_value 399 | end 400 | else 401 | arg 402 | end 403 | end 404 | 405 | def with_playwright_page(&block) 406 | assert_page_alive { 407 | block.call(@playwright_page) 408 | } 409 | end 410 | end 411 | end 412 | end 413 | -------------------------------------------------------------------------------- /lib/capybara/playwright/browser_options.rb: -------------------------------------------------------------------------------- 1 | module Capybara 2 | module Playwright 3 | class BrowserOptions 4 | def initialize(options) 5 | @options = options 6 | end 7 | 8 | LAUNCH_PARAMS = { 9 | args: nil, 10 | channel: nil, 11 | chromiumSandbox: nil, 12 | devtools: nil, 13 | downloadsPath: nil, 14 | env: nil, 15 | executablePath: nil, 16 | firefoxUserPrefs: nil, 17 | handleSIGHUP: nil, 18 | handleSIGINT: nil, 19 | handleSIGTERM: nil, 20 | headless: nil, 21 | ignoreDefaultArgs: nil, 22 | proxy: nil, 23 | slowMo: nil, 24 | # timeout: nil, 25 | tracesDir: nil, 26 | }.keys 27 | 28 | def value 29 | @options.select { |k, _| LAUNCH_PARAMS.include?(k) } 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/capybara/playwright/browser_runner.rb: -------------------------------------------------------------------------------- 1 | module Capybara 2 | module Playwright 3 | # playwright-ruby-client provides 3 methods to launch/connect browser. 4 | # 5 | # Playwright.create do |playwright| 6 | # playwright.chromium.launch do |browser| 7 | # 8 | # Playwright.connect_to_playwright_server do |playwright| ... 9 | # playwright.chromium.launch do |browser| 10 | # 11 | # Playwright.connect_to_browser_server do |browser| ... 12 | # 13 | # This class provides start/stop methods for driver. 14 | # This is responsible for 15 | # - managing PlaywrightExecution 16 | # - launching browser with given option if needed 17 | class BrowserRunner 18 | class PlaywrightConnectToPlaywrightServer 19 | def initialize(endpoint_url, options) 20 | @ws_endpoint = endpoint_url 21 | @browser_type = options[:browser_type] || :chromium 22 | unless %i(chromium firefox webkit).include?(@browser_type) 23 | raise ArgumentError.new("Unknown browser_type: #{@browser_type}") 24 | end 25 | @browser_options = BrowserOptions.new(options) 26 | end 27 | 28 | def playwright_execution 29 | @playwright_execution ||= ::Playwright.connect_to_playwright_server("#{@ws_endpoint}?browser=#{@browser_type}") 30 | end 31 | 32 | def playwright_browser 33 | browser_type = playwright_execution.playwright.send(@browser_type) 34 | browser_options = @browser_options.value 35 | browser_type.launch(**browser_options) 36 | end 37 | end 38 | 39 | class PlaywrightConnectToBrowserServer 40 | def initialize(endpoint_url) 41 | @ws_endpoint = endpoint_url 42 | end 43 | 44 | def playwright_execution 45 | @playwright_execution ||= ::Playwright.connect_to_browser_server(@ws_endpoint) 46 | end 47 | 48 | def playwright_browser 49 | playwright_execution.browser 50 | end 51 | end 52 | 53 | class PlaywrightCreate 54 | def initialize(options) 55 | @playwright_cli_executable_path = options[:playwright_cli_executable_path] || 'npx playwright' 56 | @browser_type = options[:browser_type] || :chromium 57 | unless %i(chromium firefox webkit).include?(@browser_type) 58 | raise ArgumentError.new("Unknown browser_type: #{@browser_type}") 59 | end 60 | @browser_options = BrowserOptions.new(options) 61 | end 62 | 63 | def playwright_execution 64 | @playwright_execution ||= ::Playwright.create( 65 | playwright_cli_executable_path: @playwright_cli_executable_path, 66 | ) 67 | end 68 | 69 | def playwright_browser 70 | browser_type = playwright_execution.playwright.send(@browser_type) 71 | browser_options = @browser_options.value 72 | browser_type.launch(**browser_options) 73 | end 74 | end 75 | 76 | def initialize(options) 77 | @runner = 78 | if options[:playwright_server_endpoint_url] 79 | PlaywrightConnectToPlaywrightServer.new(options[:playwright_server_endpoint_url], options) 80 | elsif options[:browser_server_endpoint_url] 81 | PlaywrightConnectToBrowserServer.new(options[:browser_server_endpoint_url]) 82 | else 83 | PlaywrightCreate.new(options) 84 | end 85 | end 86 | 87 | # @return [::Playwright::Browser] 88 | def start 89 | @playwright_execution = @runner.playwright_execution 90 | @runner.playwright_browser 91 | end 92 | 93 | def stop 94 | @playwright_execution&.stop 95 | @playwright_execution = nil 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/capybara/playwright/dialog_event_handler.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module Capybara 4 | module Playwright 5 | # LILO event handler 6 | class DialogEventHandler 7 | class Item 8 | def initialize(dialog_proc) 9 | @id = SecureRandom.uuid 10 | @proc = dialog_proc 11 | end 12 | 13 | attr_reader :id 14 | 15 | def call(dialog) 16 | @proc.call(dialog) 17 | end 18 | end 19 | 20 | def initialize 21 | @handlers = [] 22 | @mutex = Mutex.new 23 | end 24 | 25 | attr_writer :default_handler 26 | 27 | def add_handler(callable) 28 | item = Item.new(callable) 29 | @mutex.synchronize { 30 | @handlers << item 31 | } 32 | item.id 33 | end 34 | 35 | def remove_handler(id) 36 | @mutex.synchronize { 37 | @handlers.reject! { |item| item.id == id } 38 | } 39 | end 40 | 41 | def with_handler(callable, &block) 42 | id = add_handler(callable) 43 | begin 44 | block.call 45 | ensure 46 | remove_handler(id) 47 | end 48 | end 49 | 50 | def handle_dialog(dialog) 51 | handler = @mutex.synchronize { 52 | @handlers.pop || @default_handler 53 | } 54 | handler&.call(dialog) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/capybara/playwright/driver.rb: -------------------------------------------------------------------------------- 1 | require_relative './driver_extension' 2 | 3 | module Capybara 4 | module Playwright 5 | class Driver < ::Capybara::Driver::Base 6 | extend Forwardable 7 | include DriverExtension 8 | 9 | def initialize(app, **options) 10 | @browser_runner = BrowserRunner.new(options) 11 | @page_options = PageOptions.new(options) 12 | if options[:timeout].is_a?(Numeric) # just for compatibility with capybara-selenium-driver 13 | @default_navigation_timeout = options[:timeout] * 1000 14 | end 15 | if options[:default_timeout].is_a?(Numeric) 16 | @default_timeout = options[:default_timeout] * 1000 17 | end 18 | if options[:default_navigation_timeout].is_a?(Numeric) 19 | @default_navigation_timeout = options[:default_navigation_timeout] * 1000 20 | end 21 | @internal_logger = options[:logger] || default_logger 22 | end 23 | 24 | def wait?; true; end 25 | def needs_server?; true; end 26 | 27 | private def browser 28 | @browser ||= ::Capybara::Playwright::Browser.new( 29 | driver: self, 30 | internal_logger: @internal_logger, 31 | playwright_browser: playwright_browser, 32 | page_options: @page_options.value, 33 | record_video: callback_on_save_screenrecord?, 34 | callback_on_save_trace: @callback_on_save_trace, 35 | default_timeout: @default_timeout, 36 | default_navigation_timeout: @default_navigation_timeout, 37 | ) 38 | end 39 | 40 | private def playwright_browser 41 | @playwright_browser ||= create_playwright_browser 42 | end 43 | 44 | private def create_playwright_browser 45 | # clean up @playwright_browser and @playwright_execution on exit. 46 | main = Process.pid 47 | at_exit do 48 | # Store the exit status of the test run since it goes away after calling the at_exit proc... 49 | @exit_status = $ERROR_INFO.status if $ERROR_INFO.is_a?(SystemExit) 50 | quit if Process.pid == main 51 | exit @exit_status if @exit_status # Force exit with stored status 52 | end 53 | 54 | @browser_runner.start 55 | end 56 | 57 | private def default_logger 58 | if defined?(Rails) 59 | Rails.logger 60 | else 61 | PutsLogger.new 62 | end 63 | end 64 | 65 | # Since existing user already monkey-patched Kernel#puts, 66 | # (https://gist.github.com/searls/9caa12f66c45a72e379e7bfe4c48405b) 67 | # Logger.new(STDOUT) should be avoided to use. 68 | class PutsLogger 69 | def info(message) 70 | puts "[INFO] #{message}" 71 | end 72 | 73 | def warn(message) 74 | puts "[WARNING] #{message}" 75 | end 76 | end 77 | 78 | private def quit 79 | @playwright_browser&.close 80 | @playwright_browser = nil 81 | @browser_runner.stop 82 | end 83 | 84 | def reset! 85 | # screenshot is available only before closing page. 86 | if callback_on_save_screenshot? 87 | raw_screenshot = @browser&.raw_screenshot 88 | if raw_screenshot 89 | callback_on_save_screenshot(raw_screenshot) 90 | end 91 | end 92 | 93 | # video path can be aquired only before closing context. 94 | # video is completedly saved only after closing context. 95 | video_path = @browser&.video_path 96 | 97 | # [NOTE] @playwright_browser should keep alive for better performance. 98 | # Only `Browser` is disposed. 99 | @browser&.clear_browser_contexts 100 | 101 | if video_path 102 | callback_on_save_screenrecord(video_path) 103 | end 104 | 105 | @browser = nil 106 | end 107 | 108 | def invalid_element_errors 109 | @invalid_element_errors ||= [ 110 | Node::NotActionableError, 111 | Node::StaleReferenceError, 112 | ].freeze 113 | end 114 | 115 | def no_such_window_error 116 | Browser::NoSuchWindowError 117 | end 118 | 119 | # ref: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/driver/base.rb 120 | def_delegator(:browser, :current_url) 121 | def_delegator(:browser, :visit) 122 | def_delegator(:browser, :refresh) 123 | def_delegator(:browser, :find_xpath) 124 | def_delegator(:browser, :find_css) 125 | def_delegator(:browser, :title) 126 | def_delegator(:browser, :html) 127 | def_delegator(:browser, :go_back) 128 | def_delegator(:browser, :go_forward) 129 | def_delegator(:browser, :execute_script) 130 | def_delegator(:browser, :evaluate_script) 131 | def_delegator(:browser, :evaluate_async_script) 132 | def_delegator(:browser, :save_screenshot) 133 | def_delegator(:browser, :response_headers) 134 | def_delegator(:browser, :status_code) 135 | def_delegator(:browser, :active_element) 136 | def_delegator(:browser, :send_keys) 137 | def_delegator(:browser, :switch_to_frame) 138 | def_delegator(:browser, :current_window_handle) 139 | def_delegator(:browser, :window_size) 140 | def_delegator(:browser, :resize_window_to) 141 | def_delegator(:browser, :maximize_window) 142 | def_delegator(:browser, :fullscreen_window) 143 | def_delegator(:browser, :close_window) 144 | def_delegator(:browser, :window_handles) 145 | def_delegator(:browser, :open_new_window) 146 | def_delegator(:browser, :switch_to_window) 147 | def_delegator(:browser, :accept_modal) 148 | def_delegator(:browser, :dismiss_modal) 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/capybara/playwright/driver_extension.rb: -------------------------------------------------------------------------------- 1 | module Capybara 2 | module Playwright 3 | module DriverExtension 4 | # Register screenshot save process. 5 | # The callback is called just before page is closed. 6 | # (just before #reset_session!) 7 | # 8 | # The **binary** (String) of the page screenshot is called back into the given block 9 | def on_save_raw_screenshot_before_reset(&block) 10 | @callback_on_save_screenshot = block 11 | end 12 | 13 | private def callback_on_save_screenshot? 14 | !!@callback_on_save_screenshot 15 | end 16 | 17 | private def callback_on_save_screenshot(raw_screenshot) 18 | @callback_on_save_screenshot&.call(raw_screenshot) 19 | end 20 | 21 | # Register screenrecord save process. 22 | # The callback is called just after page is closed. 23 | # (just after #reset_session!) 24 | # 25 | # The video path (String) is called back into the given block 26 | def on_save_screenrecord(&block) 27 | @callback_on_save_screenrecord = block 28 | end 29 | 30 | private def callback_on_save_screenrecord? 31 | !!@callback_on_save_screenrecord 32 | end 33 | 34 | private def callback_on_save_screenrecord(video_path) 35 | @callback_on_save_screenrecord&.call(video_path) 36 | end 37 | 38 | # Register trace save process. 39 | # The callback is called just after trace is saved. 40 | # 41 | # The trace.zip path (String) is called back into the given block 42 | def on_save_trace(&block) 43 | @callback_on_save_trace = block 44 | end 45 | 46 | def with_playwright_page(&block) 47 | raise ArgumentError.new('block must be given') unless block 48 | 49 | browser.with_playwright_page(&block) 50 | end 51 | 52 | # Start Playwright tracing (doc: https://playwright.dev/docs/api/class-tracing#tracing-start) 53 | def start_tracing(name: nil, screenshots: false, snapshots: false, sources: false, title: nil) 54 | # Ensure playwright page is initialized. 55 | browser 56 | 57 | with_playwright_page do |playwright_page| 58 | playwright_page.context.tracing.start(name: name, screenshots: screenshots, snapshots: snapshots, sources: sources, title: title) 59 | end 60 | end 61 | 62 | # Stop Playwright tracing (doc: https://playwright.dev/docs/api/class-tracing#tracing-stop) 63 | def stop_tracing(path: nil) 64 | with_playwright_page do |playwright_page| 65 | playwright_page.context.tracing.stop(path: path) 66 | end 67 | end 68 | 69 | # Trace execution of the given block. The tracing is automatically stopped when the block is finished. 70 | def trace(name: nil, screenshots: false, snapshots: false, sources: false, title: nil, path: nil, &block) 71 | raise ArgumentError.new('block must be given') unless block 72 | 73 | start_tracing(name: name, screenshots: screenshots, snapshots: snapshots, sources: sources, title: title) 74 | block.call 75 | ensure 76 | stop_tracing(path: path) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/capybara/playwright/node.rb: -------------------------------------------------------------------------------- 1 | module Capybara 2 | module ElementClickOptionPatch 3 | def perform_click_action(keys, **options) 4 | # Expose `wait` value to the block given to perform_click_action. 5 | if options[:wait].is_a?(Numeric) 6 | options[:_playwright_wait] = options[:wait] 7 | end 8 | 9 | # Playwright has own auto-waiting feature. 10 | # So disable Capybara's retry logic. 11 | if driver.is_a?(Capybara::Playwright::Driver) 12 | options[:wait] = 0 13 | end 14 | 15 | super 16 | end 17 | end 18 | Node::Element.prepend(ElementClickOptionPatch) 19 | 20 | module WithElementHandlePatch 21 | def with_playwright_element_handle(&block) 22 | raise ArgumentError.new('block must be given') unless block 23 | 24 | if native.is_a?(::Playwright::ElementHandle) 25 | block.call(native) 26 | else 27 | raise "#{native.inspect} is not a Playwright::ElementHandle" 28 | end 29 | end 30 | end 31 | Node::Element.prepend(WithElementHandlePatch) 32 | 33 | module CapybaraObscuredPatch 34 | # ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L523 35 | OBSCURED_OR_OFFSET_SCRIPT = <<~JAVASCRIPT 36 | (el, [x, y]) => { 37 | var box = el.getBoundingClientRect(); 38 | if (!x && x != 0) x = box.width/2; 39 | if (!y && y != 0) y = box.height/2; 40 | var px = box.left + x, 41 | py = box.top + y, 42 | e = document.elementFromPoint(px, py); 43 | if (!el.contains(e)) 44 | return true; 45 | return { x: px, y: py }; 46 | } 47 | JAVASCRIPT 48 | 49 | def capybara_obscured?(x: nil, y: nil) 50 | res = evaluate(OBSCURED_OR_OFFSET_SCRIPT, arg: [x, y]) 51 | return true if res == true 52 | 53 | # ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/driver.rb#L182 54 | frame = owner_frame 55 | return false unless frame.parent_frame 56 | 57 | frame.frame_element.capybara_obscured?(x: res['x'], y: res['y']) 58 | end 59 | end 60 | ::Playwright::ElementHandle.prepend(CapybaraObscuredPatch) 61 | 62 | module Playwright 63 | # Selector and checking methods are derived from twapole/apparition 64 | # Action methods (click, select_option, ...) uses playwright. 65 | # 66 | # ref: 67 | # selenium: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/selenium/node.rb 68 | # apparition: https://github.com/twalpole/apparition/blob/master/lib/capybara/apparition/node.rb 69 | class Node < ::Capybara::Driver::Node 70 | def initialize(driver, internal_logger, page, element) 71 | super(driver, element) 72 | @internal_logger = internal_logger 73 | @page = page 74 | @element = element 75 | end 76 | 77 | protected def element 78 | @element 79 | end 80 | 81 | private def assert_element_not_stale(&block) 82 | # Playwright checks the staled state only when 83 | # actionable methods. (click, select_option, hover, ...) 84 | # Capybara expects stale checking also when getting inner text, and so on. 85 | @element.enabled? 86 | 87 | block.call 88 | rescue ::Playwright::Error => err 89 | case err.message 90 | when /Element is not attached to the DOM/ 91 | raise StaleReferenceError.new(err) 92 | when /Execution context was destroyed, most likely because of a navigation/ 93 | raise StaleReferenceError.new(err) 94 | when /Cannot find context with specified id/ 95 | raise StaleReferenceError.new(err) 96 | when /Unable to adopt element handle from a different document/ # for WebKit. 97 | raise StaleReferenceError.new(err) 98 | when /error in channel "content::page": exception while running method "adoptNode"/ # for Firefox 99 | raise StaleReferenceError.new(err) 100 | else 101 | raise 102 | end 103 | end 104 | 105 | private def capybara_default_wait_time 106 | Capybara.default_max_wait_time * 1100 # with 10% buffer for allowing overhead. 107 | end 108 | 109 | class NotActionableError < StandardError ; end 110 | class StaleReferenceError < StandardError ; end 111 | 112 | def all_text 113 | assert_element_not_stale { 114 | text = @element.text_content 115 | text.to_s.gsub(/[\u200b\u200e\u200f]/, '') 116 | .gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ') 117 | .gsub(/\A[[:space:]&&[^\u00a0]]+/, '') 118 | .gsub(/[[:space:]&&[^\u00a0]]+\z/, '') 119 | .tr("\u00a0", ' ') 120 | } 121 | end 122 | 123 | def visible_text 124 | assert_element_not_stale { 125 | return '' unless visible? 126 | 127 | text = @element.evaluate(<<~JAVASCRIPT) 128 | function(el){ 129 | if (el.nodeName == 'TEXTAREA'){ 130 | return el.textContent; 131 | } else if (el instanceof SVGElement) { 132 | return el.textContent; 133 | } else { 134 | return el.innerText; 135 | } 136 | } 137 | JAVASCRIPT 138 | text.to_s.scrub.gsub(/\A[[:space:]&&[^\u00a0]]+/, '') 139 | .gsub(/[[:space:]&&[^\u00a0]]+\z/, '') 140 | .gsub(/\n+/, "\n") 141 | .tr("\u00a0", ' ') 142 | } 143 | end 144 | 145 | def [](name) 146 | assert_element_not_stale { 147 | property(name) || attribute(name) 148 | } 149 | end 150 | 151 | private def property(name) 152 | value = @element.get_property(name) 153 | value.evaluate("value => ['object', 'function'].includes(typeof value) ? null : value") 154 | end 155 | 156 | private def attribute(name) 157 | @element.get_attribute(name) 158 | end 159 | 160 | def value 161 | assert_element_not_stale { 162 | # ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L31 163 | # ref: https://github.com/twalpole/apparition/blob/11aca464b38b77585191b7e302be2e062bdd369d/lib/capybara/apparition/node.rb#L728 164 | if tag_name == 'select' && @element.evaluate('el => el.multiple') 165 | @element.query_selector_all('option:checked').map do |option| 166 | option.evaluate('el => el.value') 167 | end 168 | else 169 | @element.evaluate('el => el.value') 170 | end 171 | } 172 | end 173 | 174 | def style(styles) 175 | # Capybara provides default implementation. 176 | # ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/node/element.rb#L92 177 | raise NotImplementedError 178 | end 179 | 180 | # @param value [String, Array] Array is only allowed if node has 'multiple' attribute 181 | # @param options [Hash] Driver specific options for how to set a value on a node 182 | def set(value, **options) 183 | settable_class = 184 | case tag_name 185 | when 'input' 186 | case attribute('type') 187 | when 'radio' 188 | RadioButton 189 | when 'checkbox' 190 | Checkbox 191 | when 'file' 192 | FileUpload 193 | when 'date' 194 | DateInput 195 | when 'time' 196 | TimeInput 197 | when 'datetime-local' 198 | DateTimeInput 199 | when 'color' 200 | JSValueInput 201 | when 'range' 202 | JSValueInput 203 | else 204 | TextInput 205 | end 206 | when 'textarea' 207 | TextInput 208 | else 209 | if @element.editable? 210 | TextInput 211 | else 212 | raise NotSupportedByDriverError 213 | end 214 | end 215 | 216 | settable_class.new(@element, capybara_default_wait_time, @internal_logger).set(value, **options) 217 | rescue ::Playwright::TimeoutError => err 218 | raise NotActionableError.new(err) 219 | end 220 | 221 | class Settable 222 | def initialize(element, timeout, internal_logger) 223 | @element = element 224 | @timeout = timeout 225 | @internal_logger = internal_logger 226 | end 227 | end 228 | 229 | class RadioButton < Settable 230 | def set(_, **options) 231 | @element.check(timeout: @timeout) 232 | end 233 | end 234 | 235 | class Checkbox < Settable 236 | def set(value, **options) 237 | if value 238 | @element.check(timeout: @timeout) 239 | else 240 | @element.uncheck(timeout: @timeout) 241 | end 242 | end 243 | end 244 | 245 | class TextInput < Settable 246 | def set(value, **options) 247 | text = value.to_s 248 | if text.end_with?("\n") 249 | @element.fill(text[0...-1], timeout: @timeout) 250 | @element.press('Enter', timeout: @timeout) 251 | else 252 | @element.fill(text, timeout: @timeout) 253 | end 254 | rescue ::Playwright::TimeoutError 255 | raise if @element.editable? 256 | 257 | @internal_logger.info("Node#set: element is not editable. #{@element}") 258 | end 259 | end 260 | 261 | class FileUpload < Settable 262 | def set(value, **options) 263 | file = 264 | if value.is_a?(File) 265 | value.path 266 | elsif value.is_a?(Enumerable) 267 | value.map(&:to_s) 268 | else 269 | value.to_s 270 | end 271 | @element.set_input_files(file, timeout: @timeout) 272 | end 273 | end 274 | 275 | module UpdateValueJS 276 | def update_value_js(element, value) 277 | # ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L343 278 | js = <<~JAVASCRIPT 279 | (el, value) => { 280 | if (el.readOnly) { return }; 281 | if (document.activeElement !== el){ 282 | el.focus(); 283 | } 284 | if (el.value != value) { 285 | el.value = value; 286 | el.dispatchEvent(new InputEvent('input')); 287 | el.dispatchEvent(new Event('change', { bubbles: true })); 288 | } 289 | } 290 | JAVASCRIPT 291 | element.evaluate(js, arg: value) 292 | end 293 | end 294 | 295 | class DateInput < Settable 296 | include UpdateValueJS 297 | 298 | def set(value, **options) 299 | if !value.is_a?(String) && value.respond_to?(:to_date) 300 | update_value_js(@element, value.to_date.iso8601) 301 | else 302 | @element.fill(value.to_s, timeout: @timeout) 303 | end 304 | end 305 | end 306 | 307 | class TimeInput < Settable 308 | include UpdateValueJS 309 | 310 | def set(value, **options) 311 | if !value.is_a?(String) && value.respond_to?(:to_time) 312 | update_value_js(@element, value.to_time.strftime('%H:%M')) 313 | else 314 | @element.fill(value.to_s, timeout: @timeout) 315 | end 316 | end 317 | end 318 | 319 | class DateTimeInput < Settable 320 | include UpdateValueJS 321 | 322 | def set(value, **options) 323 | if !value.is_a?(String) && value.respond_to?(:to_time) 324 | update_value_js(@element, value.to_time.strftime('%Y-%m-%dT%H:%M')) 325 | else 326 | @element.fill(value.to_s, timeout: @timeout) 327 | end 328 | end 329 | end 330 | 331 | class JSValueInput < Settable 332 | include UpdateValueJS 333 | 334 | def set(value, **options) 335 | update_value_js(@element, value) 336 | end 337 | end 338 | 339 | def select_option 340 | return false if disabled? 341 | 342 | select_element = parent_select_element 343 | if select_element.evaluate('el => el.multiple') 344 | selected_options = select_element.query_selector_all('option:checked') 345 | selected_options << @element 346 | select_element.select_option(element: selected_options, timeout: capybara_default_wait_time) 347 | else 348 | select_element.select_option(element: @element, timeout: capybara_default_wait_time) 349 | end 350 | end 351 | 352 | def unselect_option 353 | if parent_select_element.evaluate('el => el.multiple') 354 | return false if disabled? 355 | 356 | @element.evaluate('el => el.selected = false') 357 | else 358 | raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' 359 | end 360 | end 361 | 362 | private def parent_select_element 363 | @element.query_selector('xpath=ancestor::select') 364 | end 365 | 366 | def click(keys = [], **options) 367 | click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time) 368 | @element.click(**click_options.as_params) 369 | end 370 | 371 | def right_click(keys = [], **options) 372 | click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time) 373 | params = click_options.as_params 374 | params[:button] = 'right' 375 | @element.click(**params) 376 | end 377 | 378 | def double_click(keys = [], **options) 379 | click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time) 380 | @element.dblclick(**click_options.as_params) 381 | end 382 | 383 | class ClickOptions 384 | def initialize(element, keys, options, default_timeout) 385 | @element = element 386 | @modifiers = keys.map do |key| 387 | MODIFIERS[key.to_sym] or raise ArgumentError.new("Unknown modifier key: #{key}") 388 | end 389 | if options[:x] && options[:y] 390 | @coords = { 391 | x: options[:x], 392 | y: options[:y], 393 | } 394 | @offset_center = options[:offset] == :center 395 | end 396 | @wait = options[:_playwright_wait] 397 | @delay = options[:delay] 398 | @default_timeout = default_timeout 399 | end 400 | 401 | def as_params 402 | { 403 | delay: delay_ms, 404 | modifiers: modifiers, 405 | position: position, 406 | timeout: timeout + delay_ms.to_i, 407 | }.compact 408 | end 409 | 410 | private def timeout 411 | if @wait 412 | if @wait <= 0 413 | raise NotSupportedByDriverError.new("wait should be > 0 (wait = 0 is not supported on this driver)") 414 | end 415 | 416 | @wait * 1000 417 | else 418 | @default_timeout 419 | end 420 | end 421 | 422 | private def delay_ms 423 | if @delay && @delay > 0 424 | @delay * 1000 425 | else 426 | nil 427 | end 428 | end 429 | 430 | MODIFIERS = { 431 | alt: 'Alt', 432 | ctrl: 'Control', 433 | control: 'Control', 434 | meta: 'Meta', 435 | command: 'Meta', 436 | cmd: 'Meta', 437 | shift: 'Shift', 438 | }.freeze 439 | 440 | private def modifiers 441 | if @modifiers.empty? 442 | nil 443 | else 444 | @modifiers 445 | end 446 | end 447 | 448 | private def position 449 | if @offset_center 450 | box = @element.bounding_box 451 | 452 | { 453 | x: @coords[:x] + box['width'] / 2, 454 | y: @coords[:y] + box['height'] / 2, 455 | } 456 | else 457 | @coords 458 | end 459 | end 460 | end 461 | 462 | def send_keys(*args) 463 | SendKeys.new(@element, args).execute 464 | end 465 | 466 | class SendKeys 467 | MODIFIERS = { 468 | alt: 'Alt', 469 | ctrl: 'Control', 470 | control: 'Control', 471 | meta: 'Meta', 472 | command: 'Meta', 473 | cmd: 'Meta', 474 | shift: 'Shift', 475 | }.freeze 476 | 477 | KEYS = { 478 | cancel: 'Cancel', 479 | help: 'Help', 480 | backspace: 'Backspace', 481 | tab: 'Tab', 482 | clear: 'Clear', 483 | return: 'Enter', 484 | enter: 'Enter', 485 | shift: 'Shift', 486 | control: 'Control', 487 | alt: 'Alt', 488 | pause: 'Pause', 489 | escape: 'Escape', 490 | space: 'Space', 491 | page_up: 'PageUp', 492 | page_down: 'PageDown', 493 | end: 'End', 494 | home: 'Home', 495 | left: 'ArrowLeft', 496 | up: 'ArrowUp', 497 | right: 'ArrowRight', 498 | down: 'ArrowDown', 499 | insert: 'Insert', 500 | delete: 'Delete', 501 | semicolon: 'Semicolon', 502 | equals: 'Equal', 503 | numpad0: 'Numpad0', 504 | numpad1: 'Numpad1', 505 | numpad2: 'Numpad2', 506 | numpad3: 'Numpad3', 507 | numpad4: 'Numpad4', 508 | numpad5: 'Numpad5', 509 | numpad6: 'Numpad6', 510 | numpad7: 'Numpad7', 511 | numpad8: 'Numpad8', 512 | numpad9: 'Numpad9', 513 | multiply: 'NumpadMultiply', 514 | add: 'NumpadAdd', 515 | separator: 'NumpadDecimal', 516 | subtract: 'NumpadSubtract', 517 | decimal: 'NumpadDecimal', 518 | divide: 'NumpadDivide', 519 | f1: 'F1', 520 | f2: 'F2', 521 | f3: 'F3', 522 | f4: 'F4', 523 | f5: 'F5', 524 | f6: 'F6', 525 | f7: 'F7', 526 | f8: 'F8', 527 | f9: 'F9', 528 | f10: 'F10', 529 | f11: 'F11', 530 | f12: 'F12', 531 | meta: 'Meta', 532 | command: 'Meta', 533 | } 534 | 535 | def initialize(element_or_keyboard, keys) 536 | @element_or_keyboard = element_or_keyboard 537 | 538 | holding_keys = [] 539 | @executables = keys.each_with_object([]) do |key, executables| 540 | if MODIFIERS[key] 541 | holding_keys << key 542 | else 543 | if holding_keys.empty? 544 | case key 545 | when String 546 | executables << TypeText.new(key) 547 | when Symbol 548 | executables << PressKey.new( 549 | key: key_for(key), 550 | modifiers: [], 551 | ) 552 | when Array 553 | _key = key.last 554 | code = 555 | if _key.is_a?(String) && _key.length == 1 556 | _key 557 | elsif _key.is_a?(Symbol) 558 | key_for(_key) 559 | else 560 | raise ArgumentError.new("invalid key: #{_key}. Symbol of 1-length String is expected.") 561 | end 562 | modifiers = key.first(key.size - 1).map { |k| modifier_for(k) } 563 | executables << PressKey.new( 564 | key: code, 565 | modifiers: modifiers, 566 | ) 567 | end 568 | else 569 | modifiers = holding_keys.map { |k| modifier_for(k) } 570 | 571 | case key 572 | when String 573 | key.each_char do |char| 574 | executables << PressKey.new( 575 | key: char, 576 | modifiers: modifiers, 577 | ) 578 | end 579 | when Symbol 580 | executables << PressKey.new( 581 | key: key_for(key), 582 | modifiers: modifiers 583 | ) 584 | else 585 | raise ArgumentError.new("#{key} cannot be handled with holding key #{holding_keys}") 586 | end 587 | end 588 | end 589 | end 590 | end 591 | 592 | private def modifier_for(modifier) 593 | MODIFIERS[modifier] or raise ArgumentError.new("invalid modifier specified: #{modifier}") 594 | end 595 | 596 | private def key_for(key) 597 | KEYS[key] or raise ArgumentError.new("invalid key specified: #{key}") 598 | end 599 | 600 | def execute 601 | @executables.each do |executable| 602 | executable.execute_for(@element_or_keyboard) 603 | end 604 | end 605 | 606 | class PressKey 607 | def initialize(key:, modifiers:) 608 | # Shift requires an explicitly uppercase a-z key to produce the correct output 609 | # See https://playwright.dev/docs/input#keys-and-shortcuts 610 | key = key.upcase if modifiers == [MODIFIERS[:shift]] && key.match?(/^[a-z]$/) 611 | 612 | # puts "PressKey: key=#{key} modifiers: #{modifiers}" 613 | if modifiers.empty? 614 | @key = key 615 | else 616 | @key = (modifiers + [key]).join('+') 617 | end 618 | end 619 | 620 | def execute_for(element) 621 | element.press(@key) 622 | end 623 | end 624 | 625 | class TypeText 626 | def initialize(text) 627 | @text = text 628 | end 629 | 630 | def execute_for(element) 631 | element.type(@text) 632 | end 633 | end 634 | end 635 | 636 | def hover 637 | @element.hover(timeout: capybara_default_wait_time) 638 | end 639 | 640 | def drag_to(element, **options) 641 | DragTo.new(@page, @element, element.element, options).execute 642 | end 643 | 644 | class DragTo 645 | MODIFIERS = { 646 | alt: 'Alt', 647 | ctrl: 'Control', 648 | control: 'Control', 649 | meta: 'Meta', 650 | command: 'Meta', 651 | cmd: 'Meta', 652 | shift: 'Shift', 653 | }.freeze 654 | 655 | # @param page [Playwright::Page] 656 | # @param source [Playwright::ElementHandle] 657 | # @param target [Playwright::ElementHandle] 658 | def initialize(page, source, target, options) 659 | @page = page 660 | @source = source 661 | @target = target 662 | @options = options 663 | end 664 | 665 | def execute 666 | @source.scroll_into_view_if_needed 667 | 668 | # down 669 | position_from = center_of(@source) 670 | @page.mouse.move(*position_from) 671 | @page.mouse.down 672 | 673 | @target.scroll_into_view_if_needed 674 | 675 | # move and up 676 | sleep_delay 677 | position_to = center_of(@target) 678 | with_key_pressing(drop_modifiers) do 679 | @page.mouse.move(*position_to, steps: 6) 680 | sleep_delay 681 | @page.mouse.up 682 | end 683 | sleep_delay 684 | end 685 | 686 | # @param element [Playwright::ElementHandle] 687 | private def center_of(element) 688 | box = element.bounding_box 689 | [box["x"] + box["width"] / 2, box["y"] + box["height"] / 2] 690 | end 691 | 692 | private def with_key_pressing(keys, &block) 693 | keys.each { |key| @page.keyboard.down(key) } 694 | block.call 695 | keys.each { |key| @page.keyboard.up(key) } 696 | end 697 | 698 | # @returns Array 699 | private def drop_modifiers 700 | return [] unless @options[:drop_modifiers] 701 | 702 | Array(@options[:drop_modifiers]).map do |key| 703 | MODIFIERS[key.to_sym] or raise ArgumentError.new("Unknown modifier key: #{key}") 704 | end 705 | end 706 | 707 | private def sleep_delay 708 | return unless @options[:delay] 709 | 710 | sleep @options[:delay] 711 | end 712 | end 713 | 714 | def drop(*args) 715 | raise NotImplementedError 716 | end 717 | 718 | def scroll_by(x, y) 719 | js = <<~JAVASCRIPT 720 | (el, [x, y]) => { 721 | if (el.scrollBy){ 722 | el.scrollBy(x, y); 723 | } else { 724 | el.scrollTop = el.scrollTop + y; 725 | el.scrollLeft = el.scrollLeft + x; 726 | } 727 | } 728 | JAVASCRIPT 729 | 730 | @element.evaluate(js, arg: [x, y]) 731 | end 732 | 733 | def scroll_to(element, location, position = nil) 734 | # location, element = element, nil if element.is_a? Symbol 735 | if element.is_a? Capybara::Playwright::Node 736 | scroll_element_to_location(element, location) 737 | elsif location.is_a? Symbol 738 | scroll_to_location(location) 739 | else 740 | scroll_to_coords(*position) 741 | end 742 | 743 | self 744 | end 745 | 746 | private def scroll_element_to_location(element, location) 747 | scroll_opts = 748 | case location 749 | when :top 750 | 'true' 751 | when :bottom 752 | 'false' 753 | when :center 754 | "{behavior: 'instant', block: 'center'}" 755 | else 756 | raise ArgumentError, "Invalid scroll_to location: #{location}" 757 | end 758 | 759 | element.native.evaluate("(el) => { el.scrollIntoView(#{scroll_opts}) }") 760 | end 761 | 762 | SCROLL_POSITIONS = { 763 | top: '0', 764 | bottom: 'el.scrollHeight', 765 | center: '(el.scrollHeight - el.clientHeight)/2' 766 | }.freeze 767 | 768 | private def scroll_to_location(location) 769 | position = SCROLL_POSITIONS[location] 770 | 771 | @element.evaluate(<<~JAVASCRIPT) 772 | (el) => { 773 | if (el.scrollTo){ 774 | el.scrollTo(0, #{position}); 775 | } else { 776 | el.scrollTop = #{position}; 777 | } 778 | } 779 | JAVASCRIPT 780 | end 781 | 782 | private def scroll_to_coords(x, y) 783 | js = <<~JAVASCRIPT 784 | (el, [x, y]) => { 785 | if (el.scrollTo){ 786 | el.scrollTo(x, y); 787 | } else { 788 | el.scrollTop = y; 789 | el.scrollLeft = x; 790 | } 791 | } 792 | JAVASCRIPT 793 | 794 | @element.evaluate(js, arg: [x, y]) 795 | end 796 | 797 | def tag_name 798 | @tag_name ||= @element.evaluate('e => e.tagName.toLowerCase()') 799 | end 800 | 801 | def visible? 802 | assert_element_not_stale { 803 | # if an area element, check visibility of relevant image 804 | @element.evaluate(<<~JAVASCRIPT) 805 | function(el) { 806 | if (el.tagName == 'AREA'){ 807 | const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue; 808 | el = document.querySelector(`img[usemap='#${map_name}']`); 809 | if (!el){ 810 | return false; 811 | } 812 | } 813 | var forced_visible = false; 814 | while (el) { 815 | const style = window.getComputedStyle(el); 816 | if (style.visibility == 'visible') 817 | forced_visible = true; 818 | if ((style.display == 'none') || 819 | ((style.visibility == 'hidden') && !forced_visible) || 820 | (parseFloat(style.opacity) == 0)) { 821 | return false; 822 | } 823 | var parent = el.parentElement; 824 | if (parent && (parent.tagName == 'DETAILS') && !parent.open && (el.tagName != 'SUMMARY')) { 825 | return false; 826 | } 827 | el = parent; 828 | } 829 | return true; 830 | } 831 | JAVASCRIPT 832 | } 833 | end 834 | 835 | def obscured? 836 | @element.capybara_obscured? 837 | end 838 | 839 | def checked? 840 | assert_element_not_stale { 841 | @element.evaluate('el => !!el.checked') 842 | } 843 | end 844 | 845 | def selected? 846 | assert_element_not_stale { 847 | @element.evaluate('el => !!el.selected') 848 | } 849 | end 850 | 851 | def disabled? 852 | @element.evaluate(<<~JAVASCRIPT) 853 | function(el) { 854 | const xpath = 'parent::optgroup[@disabled] | \ 855 | ancestor::select[@disabled] | \ 856 | parent::fieldset[@disabled] | \ 857 | ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]'; 858 | return el.disabled || document.evaluate(xpath, el, null, XPathResult.BOOLEAN_TYPE, null).booleanValue 859 | } 860 | JAVASCRIPT 861 | end 862 | 863 | def readonly? 864 | !@element.editable? 865 | end 866 | 867 | def multiple? 868 | @element.evaluate('el => el.multiple') 869 | end 870 | 871 | def rect 872 | assert_element_not_stale { 873 | @element.evaluate(<<~JAVASCRIPT) 874 | function(el){ 875 | const rects = [...el.getClientRects()] 876 | const rect = rects.find(r => (r.height && r.width)) || el.getBoundingClientRect(); 877 | return rect.toJSON(); 878 | } 879 | JAVASCRIPT 880 | } 881 | end 882 | 883 | def path 884 | assert_element_not_stale { 885 | @element.evaluate(<<~JAVASCRIPT) 886 | (el) => { 887 | var xml = document; 888 | var xpath = ''; 889 | var pos, tempitem2; 890 | if (el.getRootNode && el.getRootNode() instanceof ShadowRoot) { 891 | return "(: Shadow DOM element - no XPath :)"; 892 | }; 893 | while(el !== xml.documentElement) { 894 | pos = 0; 895 | tempitem2 = el; 896 | while(tempitem2) { 897 | if (tempitem2.nodeType === 1 && tempitem2.nodeName === el.nodeName) { // If it is ELEMENT_NODE of the same name 898 | pos += 1; 899 | } 900 | tempitem2 = tempitem2.previousSibling; 901 | } 902 | if (el.namespaceURI != xml.documentElement.namespaceURI) { 903 | xpath = "*[local-name()='"+el.nodeName+"' and namespace-uri()='"+(el.namespaceURI===null?'':el.namespaceURI)+"']["+pos+']'+'/'+xpath; 904 | } else { 905 | xpath = el.nodeName.toUpperCase()+"["+pos+"]/"+xpath; 906 | } 907 | el = el.parentNode; 908 | } 909 | xpath = '/'+xml.documentElement.nodeName.toUpperCase()+'/'+xpath; 910 | xpath = xpath.replace(/\\/$/, ''); 911 | return xpath; 912 | } 913 | JAVASCRIPT 914 | } 915 | end 916 | 917 | def trigger(event) 918 | @element.dispatch_event(event) 919 | end 920 | 921 | def shadow_root 922 | # Playwright does not distinguish shadow DOM. 923 | # https://playwright.dev/docs/selectors#selecting-elements-in-shadow-dom 924 | # Just do with Host element as shadow root Element. 925 | # 926 | # Node.new(@driver, @page, @element.evaluate_handle('el => el.shadowRoot')) 927 | # 928 | # does not work well because of the Playwright Error 'Element is not attached to the DOM' 929 | ShadowRootNode.new(@driver, @internal_logger, @page, @element) 930 | end 931 | 932 | def inspect 933 | %(#<#{self.class} tag="#{tag_name}" path="#{path}">) 934 | end 935 | 936 | def ==(other) 937 | return false unless other.is_a?(Node) 938 | 939 | @element.evaluate('(self, other) => self == other', arg: other.element) 940 | end 941 | 942 | def find_xpath(query, **options) 943 | assert_element_not_stale { 944 | @element.query_selector_all("xpath=#{query}").map do |el| 945 | Node.new(@driver, @internal_logger, @page, el) 946 | end 947 | } 948 | end 949 | 950 | def find_css(query, **options) 951 | assert_element_not_stale { 952 | @element.query_selector_all(query).map do |el| 953 | Node.new(@driver, @internal_logger, @page, el) 954 | end 955 | } 956 | end 957 | end 958 | end 959 | end 960 | -------------------------------------------------------------------------------- /lib/capybara/playwright/page.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module Capybara 4 | module Playwright 5 | module PageExtension 6 | def initialize(*args, **kwargs) 7 | if kwargs.empty? 8 | super(*args) 9 | else 10 | super(*args, **kwargs) 11 | end 12 | capybara_initialize 13 | end 14 | 15 | private def _update_internal_logger(internal_logger) 16 | @internal_logger = internal_logger 17 | end 18 | 19 | private def capybara_initialize 20 | @capybara_all_responses = {} 21 | @capybara_last_response = nil 22 | @capybara_frames = [] 23 | 24 | on('dialog', -> (dialog) { 25 | capybara_dialog_event_handler.handle_dialog(dialog) 26 | }) 27 | on('download', -> (download) { 28 | FileUtils.mkdir_p(Capybara.save_path) 29 | dest = File.join(Capybara.save_path, download.suggested_filename) 30 | # download.save_as blocks main thread until download completes. 31 | Thread.new(dest) { |_dest| download.save_as(_dest) } 32 | }) 33 | on('response', -> (response) { 34 | @capybara_all_responses[response.url] = response 35 | }) 36 | on('framenavigated', -> (frame) { 37 | @capybara_last_response = @capybara_all_responses[frame.url] 38 | }) 39 | on('load', -> (page) { 40 | @capybara_all_responses.clear 41 | }) 42 | end 43 | 44 | private def capybara_dialog_event_handler 45 | @capybara_dialog_event_handler ||= DialogEventHandler.new.tap do |h| 46 | h.default_handler = method(:capybara_on_unexpected_modal) 47 | end 48 | end 49 | 50 | private def capybara_on_unexpected_modal(dialog) 51 | @internal_logger.warn "Unexpected modal - \"#{dialog.message}\"" 52 | if dialog.type == 'beforeunload' 53 | dialog.accept_async 54 | else 55 | dialog.dismiss 56 | end 57 | end 58 | 59 | class DialogAcceptor 60 | def initialize(dialog_type, options) 61 | @dialog_type = dialog_type 62 | @options = options 63 | end 64 | 65 | def handle(dialog) 66 | if @dialog_type == :prompt 67 | dialog.accept_async(promptText: @options[:with] || dialog.default_value) 68 | else 69 | dialog.accept_async 70 | end 71 | end 72 | end 73 | 74 | class DialogMessageMatcher 75 | def initialize(text_or_regex_or_nil) 76 | if [NilClass, Regexp, String].none? { |k| text_or_regex_or_nil.is_a?(k) } 77 | raise ArgumentError.new("invalid type: #{text_or_regex_or_nil.inspect}") 78 | end 79 | 80 | @filter = text_or_regex_or_nil 81 | end 82 | 83 | def matches?(message) 84 | case @filter 85 | when nil 86 | true 87 | when Regexp 88 | message =~ @filter 89 | when String 90 | message&.include?(@filter) 91 | end 92 | end 93 | end 94 | 95 | def capybara_accept_modal(dialog_type, **options, &block) 96 | timeout_sec = options[:wait] 97 | acceptor = DialogAcceptor.new(dialog_type, options) 98 | matcher = DialogMessageMatcher.new(options[:text]) 99 | message_promise = Concurrent::Promises.resolvable_future 100 | handler = -> (dialog) { 101 | message = dialog.message 102 | if matcher.matches?(message) 103 | message_promise.fulfill(message) 104 | acceptor.handle(dialog) 105 | else 106 | message_promise.reject(Capybara::ModalNotFound.new("Dialog message=\"#{message}\" doesn't match")) 107 | dialog.dismiss 108 | end 109 | } 110 | capybara_dialog_event_handler.with_handler(handler) do 111 | block.call 112 | 113 | message = message_promise.value!(timeout_sec) 114 | if message_promise.fulfilled? 115 | message 116 | else 117 | # timed out 118 | raise Capybara::ModalNotFound 119 | end 120 | end 121 | end 122 | 123 | def capybara_dismiss_modal(dialog_type, **options, &block) 124 | timeout_sec = options[:wait] 125 | matcher = DialogMessageMatcher.new(options[:text]) 126 | message_promise = Concurrent::Promises.resolvable_future 127 | handler = -> (dialog) { 128 | message = dialog.message 129 | if matcher.matches?(message) 130 | message_promise.fulfill(message) 131 | else 132 | message_promise.reject(Capybara::ModalNotFound.new("Dialog message=\"#{message}\" doesn't match")) 133 | end 134 | dialog.dismiss 135 | } 136 | capybara_dialog_event_handler.with_handler(handler) do 137 | block.call 138 | 139 | message = message_promise.value!(timeout_sec) 140 | if message_promise.fulfilled? 141 | message 142 | else 143 | # timed out 144 | raise Capybara::ModalNotFound 145 | end 146 | end 147 | end 148 | 149 | class Headers < Hash 150 | def [](key) 151 | # Playwright accepts lower-cased keys. 152 | # However allow users to specify "Content-Type" or "User-Agent". 153 | super(key.downcase) 154 | end 155 | end 156 | 157 | def capybara_response_headers 158 | headers = @capybara_last_response&.headers || {} 159 | 160 | Headers.new.tap do |h| 161 | headers.each do |key, value| 162 | h[key] = value 163 | end 164 | end 165 | end 166 | 167 | def capybara_status_code 168 | @capybara_last_response&.status.to_i 169 | end 170 | 171 | def capybara_reset_frames 172 | @capybara_frames.clear 173 | end 174 | 175 | # @param frame [Playwright::Frame] 176 | def capybara_push_frame(frame) 177 | @capybara_frames << frame 178 | end 179 | 180 | def capybara_pop_frame 181 | @capybara_frames.pop 182 | end 183 | 184 | def capybara_current_frame 185 | @capybara_frames.last || main_frame 186 | end 187 | end 188 | ::Playwright::Page.prepend(PageExtension) 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/capybara/playwright/page_options.rb: -------------------------------------------------------------------------------- 1 | module Capybara 2 | module Playwright 3 | class PageOptions 4 | def initialize(options) 5 | @options = options 6 | end 7 | 8 | NEW_PAGE_PARAMS = { 9 | acceptDownloads: nil, 10 | bypassCSP: nil, 11 | colorScheme: nil, 12 | deviceScaleFactor: nil, 13 | extraHTTPHeaders: nil, 14 | geolocation: nil, 15 | hasTouch: nil, 16 | httpCredentials: nil, 17 | ignoreHTTPSErrors: nil, 18 | isMobile: nil, 19 | javaScriptEnabled: nil, 20 | locale: nil, 21 | noViewport: nil, 22 | offline: nil, 23 | permissions: nil, 24 | proxy: nil, 25 | record_har_omit_content: nil, 26 | record_har_path: nil, 27 | record_video_dir: nil, 28 | record_video_size: nil, 29 | reducedMotion: nil, 30 | screen: nil, 31 | serviceWorkers: nil, 32 | storageState: nil, 33 | timezoneId: nil, 34 | userAgent: nil, 35 | viewport: nil, 36 | }.keys 37 | 38 | def value 39 | @options.select { |k, _| NEW_PAGE_PARAMS.include?(k) }.tap do |options| 40 | # Set default value 41 | options[:acceptDownloads] = true 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/capybara/playwright/shadow_root_node.rb: -------------------------------------------------------------------------------- 1 | require_relative './node' 2 | 3 | module Capybara 4 | module Playwright 5 | class ShadowRootNode < Node 6 | def initialize(driver, internal_logger, page, element) 7 | super 8 | @shadow_roow_element = element.evaluate_handle('el => el.shadowRoot') 9 | end 10 | 11 | def all_text 12 | assert_element_not_stale { 13 | text = @shadow_roow_element.text_content 14 | text.to_s.gsub(/[\u200b\u200e\u200f]/, '') 15 | .gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ') 16 | .gsub(/\A[[:space:]&&[^\u00a0]]+/, '') 17 | .gsub(/[[:space:]&&[^\u00a0]]+\z/, '') 18 | .tr("\u00a0", ' ') 19 | } 20 | end 21 | 22 | def visible_text 23 | assert_element_not_stale { 24 | 25 | return '' unless visible? 26 | 27 | # https://github.com/teamcapybara/capybara/blob/1c164b608fa6452418ec13795b293655f8a0102a/lib/capybara/rack_test/node.rb#L18 28 | displayed_text = @shadow_roow_element.text_content.to_s. 29 | gsub(/[\u200b\u200e\u200f]/, ''). 30 | gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ') 31 | displayed_text.squeeze(' ') 32 | .gsub(/[\ \n]*\n[\ \n]*/, "\n") 33 | .gsub(/\A[[:space:]&&[^\u00a0]]+/, '') 34 | .gsub(/[[:space:]&&[^\u00a0]]+\z/, '') 35 | .tr("\u00a0", ' ') 36 | } 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/capybara/playwright/tmpdir_owner.rb: -------------------------------------------------------------------------------- 1 | module Capybara 2 | module Playwright 3 | module TmpdirOwner 4 | require 'tmpdir' 5 | 6 | def tmpdir 7 | return @tmpdir if @tmpdir 8 | 9 | dir = Dir.mktmpdir 10 | ObjectSpace.define_finalizer(self, TmpdirRemover.new(dir)) 11 | @tmpdir = dir 12 | end 13 | 14 | def remove_tmpdir 15 | if @tmpdir 16 | FileUtils.remove_entry(@tmpdir, true) 17 | ObjectSpace.undefine_finalizer(self) 18 | @tmpdir = nil 19 | end 20 | end 21 | 22 | class TmpdirRemover 23 | def initialize(tmpdir) 24 | @pid = Process.pid 25 | @tmpdir = tmpdir 26 | end 27 | 28 | def call(*args) 29 | return if @pid != Process.pid 30 | 31 | begin 32 | FileUtils.remove_entry(@tmpdir, true) 33 | rescue => err 34 | $stderr.puts err 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/capybara/playwright/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Playwright 5 | VERSION = '0.5.6' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/capybara/playwright/dialog_event_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Capybara::Playwright::DialogEventHandler do 4 | let(:handler) { Capybara::Playwright::DialogEventHandler.new } 5 | let(:mock_dialog) { double('dialog', message: 'dialog message') } 6 | 7 | it 'can add handler' do 8 | callback = double('callback') 9 | expect(callback).to receive(:called) 10 | handler.add_handler(-> (dialog) { 11 | callback.called(dialog) 12 | }) 13 | handler.handle_dialog(mock_dialog) 14 | end 15 | 16 | it 'can remove handler' do 17 | callback = double('callback') 18 | expect(callback).not_to receive(:called) 19 | id = handler.add_handler(-> (dialog) { 20 | callback.called(dialog) 21 | }) 22 | handler.remove_handler(id) 23 | handler.handle_dialog(mock_dialog) 24 | end 25 | 26 | it 'handles last added handler' do 27 | callback1 = double('callback1') 28 | callback2 = double('callback1') 29 | expect(callback1).not_to receive(:called) 30 | expect(callback2).to receive(:called) 31 | handler.add_handler(-> (dialog) { 32 | callback1.called(dialog) 33 | }) 34 | handler.add_handler(-> (dialog) { 35 | callback2.called(dialog) 36 | }) 37 | handler.handle_dialog(mock_dialog) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/capybara/playwright_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'capybara/spec/spec_helper' 5 | 6 | module TestSessions 7 | Playwright = Capybara::Session.new(:playwright, TestApp) 8 | end 9 | 10 | Capybara::SpecHelper.run_specs TestSessions::Playwright, 'Playwright' do |example| 11 | # example: 12 | # CAPYBARA_SPEC_FILTER=shadow_root bundle exec rspec spec/capybara/playwright_spec.rb 13 | if ENV['CAPYBARA_SPEC_FILTER'] 14 | # Skip all tests that are not matching with the filter 15 | skip unless example.metadata[:full_description].include?(ENV['CAPYBARA_SPEC_FILTER']) 16 | end 17 | 18 | case example.metadata[:full_description] 19 | when /should offset outside (the|from center of) element/ 20 | pending 'Playwright does not allow to click outside the element' 21 | when /should not retry clicking when wait is disabled/ 22 | pending 'wait = 0 is not supported' 23 | when /when details is toggled open and closed/ 24 | pending "NoMethodError: undefined method `and' for #" 25 | when /Element#drop/ 26 | pending 'not implemented' 27 | when /drag_to.*HTML5/ 28 | skip 'not supported yet in Playwright driver' 29 | when /Playwright Capybara::Window#maximize/, 30 | /Playwright Capybara::Window#fullscreen/ 31 | skip 'not supported in Playwright driver' 32 | when /Playwright #has_field with validation message/ 33 | # HTML5 validation message is a bit different. 34 | # expected: /match the requested format/ 35 | # obserbed: "Match the requested format" 36 | pending 'HTML5 validation message is a bit different.' if ENV['BROWSER'] == 'webkit' 37 | when /Playwright #refresh it reposts/ 38 | # ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/spec/selenium_spec_safari.rb#L62 39 | pending "WebKit opens an alert that can't be closed" if ENV['BROWSER'] == 'webkit' 40 | when /shadow_root should produce error messages when failing/ 41 | pending "Probably Capybara would assume only Selenium driver." 42 | when /fill_in should handle carriage returns with line feeds in a textarea correctly/ 43 | # https://github.com/teamcapybara/capybara/commit/a9dd889b640759925bd04c4991de086160242fae#diff-b62b86ae4de5582bd37146266622e3debbdcab6bab6e95f522185c6a4269067dR82 44 | pending "Not sure what firefox is doing here" if ENV['BROWSER'] == 'firefox' 45 | when /#has_element\? should be true if the given element is on the page/ 46 | pending 'https://github.com/teamcapybara/capybara/pull/2751' 47 | end 48 | 49 | Capybara::SpecHelper.reset! 50 | end 51 | -------------------------------------------------------------------------------- /spec/feature/assertion_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'assertion', sinatra: true do 4 | before do 5 | sinatra.get '/' do 6 | <<~HTML 7 | 8 |

click the go button

9 | 18 | 19 | HTML 20 | end 21 | 22 | sinatra.get '/working.html' do 23 | <<~HTML 24 | working 25 | 36 | HTML 37 | end 38 | 39 | sinatra.get '/finish.html' do 40 | 'finish' 41 | end 42 | 43 | sinatra.get '/猫' do 44 | 'cat' 45 | end 46 | end 47 | 48 | it 'survives against navigation' do 49 | visit '/' 50 | 51 | click_on 'go' 52 | expect(page).to have_content('finish') 53 | end 54 | 55 | it 'survives against navigation with refresh' do 56 | visit '/' 57 | 58 | click_on 'go' 59 | sleep 0.5 60 | refresh 61 | expect(page).to have_content('finish') 62 | end 63 | 64 | it 'can access paths using 2-byte characters' do 65 | visit '/猫' 66 | 67 | expect(page).to have_content('cat') 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/feature/attach_file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'attach file' do 4 | before do 5 | visit 'about:blank' 6 | page.driver.with_playwright_page do |page| 7 | page.content = '' 8 | end 9 | end 10 | 11 | it 'should accept String' do 12 | find('input').set(File.join('./', 'Gemfile')) 13 | end 14 | 15 | it 'should accept File' do 16 | find('input').set(File.new('./Gemfile')) 17 | end 18 | 19 | it 'should accept Pathname' do 20 | find('input').set(Pathname.new('./').join('Gemfile')) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/feature/example_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'tmpdir' 3 | 4 | RSpec.describe 'Example' do 5 | around do |example| 6 | previous_wait_time = Capybara.default_max_wait_time 7 | Capybara.default_max_wait_time = 15 8 | example.run 9 | Capybara.default_max_wait_time = previous_wait_time 10 | end 11 | 12 | if ENV['CI'] 13 | before do |example| 14 | Capybara.current_session.driver.on_save_screenrecord do |video_path| 15 | next unless defined?(Allure) 16 | 17 | Allure.add_attachment( 18 | name: "screenrecord - #{example.description}", 19 | source: File.read(video_path), 20 | type: Allure::ContentType::WEBM, 21 | test_case: true, 22 | ) 23 | end 24 | 25 | Capybara.current_session.driver.on_save_trace do |trace_path| 26 | next unless defined?(Allure) 27 | 28 | Allure.add_attachment( 29 | name: "trace - #{example.description}", 30 | source: File.read(trace_path), 31 | type: 'application/zip', 32 | test_case: true, 33 | ) 34 | end 35 | end 36 | end 37 | 38 | it 'take a screenshot' do 39 | Capybara.app_host = 'https://github.com' 40 | visit '/YusukeIwaki' 41 | expect(status_code).to eq(200) 42 | page.save_screenshot('YusukeIwaki.png') 43 | end 44 | 45 | it 'can download file' do 46 | Capybara.app_host = 'https://github.com' 47 | Dir.mktmpdir do |dir| 48 | Capybara.save_path = File.join(dir, 'foo', 'bar') 49 | visit '/YusukeIwaki/capybara-playwright-driver' 50 | 51 | page.driver.with_playwright_page do |page| 52 | page.locator('button', hasText: 'Code').click 53 | download = page.expect_download do 54 | page.click('text=Download ZIP') 55 | end 56 | output_path = File.join(dir, 'foo', 'bar', download.suggested_filename) 57 | sleep 1 # wait for save complete 58 | expect(File.exist?(output_path)).to eq(true) 59 | end 60 | 61 | expect(File.exist?(File.join(dir, 'foo', 'bar', 'capybara-playwright-driver-main.zip'))).to eq(true) 62 | end 63 | end 64 | 65 | it 'search capybara' do 66 | Capybara.app_host = 'https://github.com' 67 | visit '/' 68 | expect(status_code).to eq(200) 69 | 70 | first('div.search-input-container').click 71 | fill_in('query-builder-test', with: 'Capybara') 72 | 73 | first('[aria-label="Capybara, Search all of GitHub"]').click 74 | 75 | all('[data-testid="results-list"] h3').each do |li| 76 | puts "#{li.all('a').first.text} by Capybara" 77 | end 78 | end 79 | 80 | it 'search capybara using Playwright-native selector and action' do 81 | Capybara.app_host = 'https://github.com' 82 | visit '/' 83 | first('div.search-input-container').click 84 | fill_in('query-builder-test', with: 'Capybara') 85 | 86 | page.driver.with_playwright_page do |page| 87 | page.get_by_label('Capybara, Search all of GitHub').click 88 | end 89 | 90 | all('[data-testid="results-list"] h3').each do |li| 91 | puts "#{li.with_playwright_element_handle { |handle| handle.text_content }} by Playwright" 92 | end 93 | end 94 | 95 | context 'send_keys' do 96 | it 'can send keys without modifier' do 97 | Capybara.app_host = 'https://github.com' 98 | visit '/' 99 | 100 | find('body').send_keys ['s'] 101 | 102 | expect(page).to have_field('query-builder-test') 103 | end 104 | 105 | it 'can send keys with modifier' do 106 | Capybara.app_host = 'https://tailwindcss.com/' 107 | visit '/' 108 | 109 | find('body').send_keys [:control, 'k'] 110 | 111 | expect(page).to have_field('docsearch-input') 112 | end 113 | 114 | it 'can shift+modifier' do 115 | Capybara.app_host = 'https://github.com' 116 | visit '/' 117 | 118 | expect_any_instance_of(Playwright::ElementHandle).to receive(:press).with('Shift+Home') 119 | 120 | find('body').send_keys %i[shift home] 121 | end 122 | end 123 | 124 | it 'does not silently pass when browser has not been started' do 125 | expect do 126 | page.driver.with_playwright_page do |_page| 127 | raise 'this block actually executed' 128 | end 129 | end.to raise_error(RuntimeError, 'this block actually executed') 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/feature/timeout_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'timeout', sinatra: true do 4 | before do 5 | sinatra.get('/sleep') do 6 | sleep params[:s].to_i 7 | 'OK' 8 | end 9 | sinatra.get('/ng_to_ok') do 10 | <<~HTML 11 | 12 | NG 13 | 14 | 19 | HTML 20 | end 21 | end 22 | 23 | around do |example| 24 | original_wait_time = Capybara.default_max_wait_time 25 | Capybara.default_max_wait_time = 1 26 | example.run 27 | Capybara.default_max_wait_time = original_wait_time 28 | end 29 | 30 | it 'does not timeout with driver without timeout option', driver: :playwright do 31 | visit '/sleep?s=6' 32 | expect(page).to have_content('OK') 33 | end 34 | 35 | it 'does timeout when navigation exceeds 30 seconds with driver without timeout option', driver: :playwright do 36 | expect { visit '/sleep?s=31' }.to raise_error(Playwright::TimeoutError) 37 | end 38 | 39 | it 'does timeout when navigation exceeds the specified timeout value', driver: :playwright_timeout_2 do 40 | original_browser_options_value_method = Capybara::Playwright::BrowserOptions.instance_method(:value) 41 | allow_any_instance_of(Capybara::Playwright::BrowserOptions).to receive(:value) do |instance| 42 | # force extend the launch timeout for checking if the timeout is used for navigation. 43 | options = original_browser_options_value_method.bind(instance).call 44 | options[:timeout] = 30000 45 | options 46 | end 47 | expect { visit '/sleep?s=3' }.to raise_error(Playwright::TimeoutError) 48 | end 49 | 50 | it 'does timeout respecting default_navigation_timeout option', driver: :playwright_timeout_2_default_timeout_3_default_navigation_timeout_4 do 51 | visit '/sleep?s=3' 52 | expect(page).to have_content('OK') 53 | end 54 | 55 | it "respects the custom default time out", driver: :playwright_timeout_2_default_timeout_3 do 56 | visit "/ng_to_ok?s=5" 57 | 58 | page.driver.with_playwright_page do |playwright_page| 59 | expect { 60 | playwright_page.get_by_text('OK').text_content 61 | }.to raise_error(Playwright::TimeoutError, /Timeout 3000ms exceeded/) 62 | end 63 | 64 | visit "/ng_to_ok?s=2" 65 | 66 | page.driver.with_playwright_page do |playwright_page| 67 | expect(playwright_page.get_by_text('OK').text_content).to eq('OK') 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/feature/tracing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'tracing', sinatra: true do 4 | TRACES_DIR = 'tmp/capybara/playwright'.freeze 5 | 6 | before do 7 | FileUtils.rm_rf(TRACES_DIR) 8 | 9 | sinatra.get '/' do 10 | <<~HTML 11 | 12 | 13 | HTML 14 | end 15 | end 16 | 17 | it 'can start and stop tracing' do 18 | page.driver.start_tracing(name: "test_trace", screenshots: true, snapshots: true, sources: true, title: "test_trace") 19 | 20 | visit '/' 21 | click_on 'Go' 22 | expect(page).to have_content('Go') 23 | 24 | page.driver.stop_tracing(path: "#{TRACES_DIR}/test_trace.zip") 25 | 26 | expect(File).to exist("#{TRACES_DIR}/test_trace.zip") 27 | end 28 | 29 | it 'can enable tracing only in the block' do 30 | page.driver.trace name: "test_trace_with_block", screenshots: true, snapshots: true, sources: true, title: "title", path: "#{TRACES_DIR}/test_trace_with_block.zip" do 31 | visit '/' 32 | click_on 'Go' 33 | expect(page).to have_content('Go') 34 | end 35 | 36 | expect(File).to exist("#{TRACES_DIR}/test_trace_with_block.zip") 37 | end 38 | 39 | it 'does not start tracing when no block is given' do 40 | expect { page.driver.trace }.to raise_error(ArgumentError) 41 | 42 | expect { 43 | page.driver.start_tracing 44 | page.driver.stop_tracing 45 | }.not_to raise_error(Playwright::Error, /Tracing has been already started/) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'allure-rspec' 5 | require 'capybara/playwright' 6 | require 'capybara/rspec' 7 | require 'rack/test_server' 8 | require 'sinatra/base' 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = '.rspec_status' 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.define_derived_metadata(file_path: %r(/spec/feature/)) do |metadata| 22 | metadata[:type] = :feature 23 | end 24 | 25 | config.around(:each, sinatra: true) do |example| 26 | @sinatra = Class.new(Sinatra::Base) 27 | 28 | test_server = Rack::TestServer.new( 29 | app: @sinatra, 30 | server: :webrick, 31 | Host: '127.0.0.1', 32 | Port: 4567) 33 | 34 | test_server.start_async 35 | test_server.wait_for_ready 36 | Capybara.app_host = 'http://localhost:4567' 37 | 38 | previous_wait_time = Capybara.default_max_wait_time 39 | Capybara.default_max_wait_time = 5 40 | example.run 41 | Capybara.default_max_wait_time = previous_wait_time 42 | 43 | test_server.stop_async 44 | test_server.wait_for_stopped 45 | end 46 | 47 | test_with_sinatra = Module.new do 48 | attr_reader :sinatra 49 | end 50 | config.include(test_with_sinatra, sinatra: true) 51 | end 52 | 53 | driver_opts = { 54 | browser_server_endpoint_url: ENV['BROWSER_SERVER_ENDPOINT_URL'], 55 | playwright_server_endpoint_url: ENV['PLAYWRIGHT_SERVER_ENDPOINT_URL'], 56 | playwright_cli_executable_path: ENV['PLAYWRIGHT_CLI_EXECUTABLE_PATH'], 57 | browser_type: (ENV['BROWSER'] || 'chromium').to_sym, 58 | headless: ENV['CI'] ? true : false, 59 | } 60 | 61 | Capybara.register_driver(:playwright) do |app| 62 | Capybara::Playwright::Driver.new(app, **driver_opts, logger: Logger.new($stdout)) 63 | end 64 | 65 | Capybara.register_driver(:playwright_timeout_2) do |app| 66 | Capybara::Playwright::Driver.new(app, **driver_opts, timeout: 2) 67 | end 68 | 69 | Capybara.register_driver(:playwright_timeout_2_default_timeout_3) do |app| 70 | Capybara::Playwright::Driver.new(app, **driver_opts, timeout: 2, default_timeout: 3) 71 | end 72 | 73 | Capybara.register_driver(:playwright_timeout_2_default_timeout_3_default_navigation_timeout_4) do |app| 74 | Capybara::Playwright::Driver.new(app, **driver_opts, timeout: 2, default_timeout: 3, default_navigation_timeout: 4) 75 | end 76 | 77 | Capybara.default_driver = :playwright 78 | Capybara.save_path = 'tmp/capybara' 79 | Capybara.server = :webrick 80 | --------------------------------------------------------------------------------