├── .yardopts ├── Rakefile ├── CODEOWNERS ├── .rspec ├── Makefile ├── lib └── percy │ ├── version.rb │ └── capybara.rb ├── spec ├── fixture │ └── index.html ├── spec_helper.rb └── lib │ └── percy │ └── percy_capybara_spec.rb ├── package.json ├── .gitignore ├── .rubocop.yml ├── .github ├── workflows │ ├── changelog.yml │ ├── lint.yml │ ├── release.yml │ ├── stale.yml │ ├── Semgrep.yml │ └── test.yml ├── dependabot.yml ├── release-drafter.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── Gemfile ├── Guardfile ├── LICENSE ├── percy-capybara.gemspec ├── Gemfile.lock └── README.md /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --no-private 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @percy/percy-product-reviewers 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format d 4 | 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | release: 2 | rake build 3 | gem push pkg/percy-capybara-* 4 | -------------------------------------------------------------------------------- /lib/percy/version.rb: -------------------------------------------------------------------------------- 1 | module PercyCapybara 2 | VERSION = '5.0.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixture/index.html: -------------------------------------------------------------------------------- 1 | I am a pageSnapshot me 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "test": "percy exec --testing -- bundle exec rspec" 5 | }, 6 | "devDependencies": { 7 | "@percy/cli": "^1.16.0" 8 | } 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.bundle 10 | *.so 11 | *.o 12 | *.a 13 | *.rbc 14 | mkmf.log 15 | .DS_Store 16 | node_modules/ 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | percy-style: 3 | - default.yml 4 | RSpec/InstanceVariable: 5 | Exclude: 6 | - 7 | RSpec/ContextWording: 8 | Enabled: false 9 | AllCops: 10 | TargetRubyVersion: 2.4 11 | Include: 12 | - lib/**/*.rb 13 | - spec/lib/**/*.rb 14 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Changelog 2 | on: 3 | push: 4 | branches: [master] 5 | jobs: 6 | update_draft: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: release-drafter/release-drafter@v5 10 | env: 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in percy-capybara.gemspec 4 | gemspec 5 | 6 | gem 'guard-rspec', require: false 7 | 8 | # (for development) 9 | # gem 'percy-client', path: '~/src/percy-client' 10 | 11 | group :test, :development do 12 | gem 'pry' 13 | gem 'puma', '5.6.6' 14 | gem 'webmock' 15 | gem 'simplecov', require: false 16 | end 17 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: 'bundle exec rspec' do 2 | require 'guard/rspec/dsl' 3 | dsl = Guard::RSpec::Dsl.new(self) 4 | 5 | # RSpec files 6 | rspec = dsl.rspec 7 | watch(rspec.spec_helper) { rspec.spec_dir } 8 | watch(rspec.spec_support) { rspec.spec_dir } 9 | watch(rspec.spec_files) 10 | 11 | # Ruby files 12 | ruby = dsl.ruby 13 | dsl.watch_spec_files_for(ruby.lib_files) 14 | end 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: / 5 | labels: 6 | - ⬆️⬇️ dependencies 7 | schedule: 8 | interval: weekly 9 | commit-message: 10 | prefix: ⬆️ 11 | - package-ecosystem: github-actions 12 | directory: / 13 | schedule: 14 | interval: weekly 15 | commit-message: 16 | prefix: ⬆️👷 17 | labels: 18 | - ⬆️⬇️ dependencies 19 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | workflow_dispatch: 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: 2.6 16 | bundler-cache: true 17 | - uses: actions/cache@v3.0.11 18 | with: 19 | path: "./vendor/bundle" 20 | key: v1/${{ runner.os }}/ruby-2.6/${{ hashFiles('**/Gemfile.lock') }} 21 | restore-keys: v1/${{ runner.os }}/ruby-2.6/ 22 | - run: bundle exec rubocop 23 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '💥 Breaking Changes' 5 | labels: 6 | - 💥 breaking 7 | - title: '✨ Enhancements' 8 | labels: 9 | - ✨ enhancement 10 | - title: '🐛 Bug Fixes' 11 | labels: 12 | - 🐛 bug 13 | - title: '🏗 Maintenance' 14 | labels: 15 | - 🧹 maintenance 16 | - title: '⬆️⬇️ Dependency Updates' 17 | labels: 18 | - ⬆️⬇️ dependencies 19 | change-title-escapes: '\<*_&#@' 20 | version-resolver: 21 | major: 22 | labels: 23 | - 💥 breaking 24 | minor: 25 | labels: 26 | - ✨ enhancement 27 | default: patch 28 | template: '$CHANGES' 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: ruby/setup-ruby@v1 11 | with: 12 | ruby-version: 2.6 13 | bundler-cache: true 14 | - uses: actions/cache@v3.0.11 15 | with: 16 | path: "./vendor/bundle" 17 | key: v1/${{ runner.os }}/ruby-2.6/${{ hashFiles('**/Gemfile.lock') }} 18 | restore-keys: v1/${{ runner.os }}/ruby-2.6/ 19 | - uses: cadwallion/publish-rubygems-action@master 20 | env: 21 | RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} 22 | RELEASE_COMMAND: make release 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Perceptual Inc. 2 | 3 | The MIT License (MIT) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '0 19 * * 2' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v6 11 | with: 12 | stale-issue-message: >- 13 | This issue is stale because it has been open for more than 14 days with no activity. 14 | Remove stale label or comment or this will be closed in 14 days. 15 | stale-pr-message: >- 16 | This PR is stale because it has been open for more than 14 days with no activity. 17 | Remove stale label or comment or this will be closed in 14 days. 18 | close-issue-message: >- 19 | This issue was closed because it has been stalled for 28 days with no activity. 20 | close-pr-message: >- 21 | This PR was closed because it has been stalled for 28 days with no activity. 22 | days-before-issue-stale: 14 23 | days-before-pr-stale: 14 24 | # close 14 days _after_ initial warning 25 | days-before-issue-close: 14 26 | days-before-pr-close: 14 27 | exempt-pr-labels: '❄️ on ice' 28 | exempt-issue-labels: '🐛 bug,❄️ on ice,✨ enhancement' 29 | exempt-all-assignees: true 30 | stale-pr-label: '🍞 stale' 31 | stale-issue-label: '🍞 stale' 32 | -------------------------------------------------------------------------------- /percy-capybara.gemspec: -------------------------------------------------------------------------------- 1 | require_relative './lib/percy/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = 'percy-capybara' 5 | spec.version = PercyCapybara::VERSION 6 | spec.authors = ['Perceptual Inc.'] 7 | spec.email = ['team@percy.io'] 8 | spec.summary = %q{Percy visual testing for Capybara} 9 | spec.description = %q{} 10 | spec.homepage = '' 11 | spec.license = 'MIT' 12 | spec.required_ruby_version = '>= 2.3.0' 13 | 14 | spec.metadata = { 15 | 'bug_tracker_uri' => 'https://github.com/percy/percy-capybara/issues', 16 | 'source_code_uri' => 'https://github.com/percy/percy-capybara', 17 | } 18 | 19 | spec.files = Dir.glob("{lib}/**/*") + %w(LICENSE README.md) 20 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 21 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 22 | spec.require_paths = ['lib'] 23 | 24 | spec.add_runtime_dependency 'capybara', '>= 3' 25 | 26 | spec.add_development_dependency 'selenium-webdriver', '>= 4.0.0' 27 | spec.add_development_dependency 'geckodriver-bin', '~> 0.28.0' 28 | spec.add_development_dependency 'bundler', '>= 2.0' 29 | spec.add_development_dependency 'rake', '~> 13.0' 30 | spec.add_development_dependency 'rspec', '~> 3.5' 31 | spec.add_development_dependency 'capybara', '>= 3' 32 | spec.add_development_dependency 'percy-style', '~> 0.7.0' 33 | end 34 | -------------------------------------------------------------------------------- /.github/workflows/Semgrep.yml: -------------------------------------------------------------------------------- 1 | # Name of this GitHub Actions workflow. 2 | name: Semgrep 3 | 4 | on: 5 | # Scan changed files in PRs (diff-aware scanning): 6 | # The branches below must be a subset of the branches above 7 | pull_request: 8 | branches: ["master", "main"] 9 | push: 10 | branches: ["master", "main"] 11 | schedule: 12 | - cron: '0 6 * * *' 13 | 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | semgrep: 20 | # User definable name of this GitHub Actions job. 21 | permissions: 22 | contents: read # for actions/checkout to fetch code 23 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 24 | name: semgrep/ci 25 | # If you are self-hosting, change the following `runs-on` value: 26 | runs-on: ubuntu-latest 27 | 28 | container: 29 | # A Docker image with Semgrep installed. Do not change this. 30 | image: returntocorp/semgrep 31 | 32 | # Skip any PR created by dependabot to avoid permission issues: 33 | if: (github.actor != 'dependabot[bot]') 34 | 35 | steps: 36 | # Fetch project source with GitHub Actions Checkout. 37 | - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 38 | # Run the "semgrep ci" command on the command line of the docker image. 39 | - run: semgrep ci --sarif --output=semgrep.sarif 40 | env: 41 | # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. 42 | SEMGREP_RULES: p/default # more at semgrep.dev/explore 43 | 44 | - name: Upload SARIF file for GitHub Advanced Security Dashboard 45 | uses: github/codeql-action/upload-sarif@489225d82a57396c6f426a40e66d461b16b3461d # v2.20.4 46 | with: 47 | sarif_file: semgrep.sarif 48 | if: always() -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us fix the issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 22 | 23 | ## The problem 24 | 25 | Briefly describe the issue you are experiencing (or the feature you want to see 26 | added to Percy). Tell us what you were trying to do and what happened 27 | instead. Remember, this is _not_ a place to ask questions. For that, go to 28 | https://github.com/percy/cli/discussions/new 29 | 30 | ## Environment 31 | 32 | - Node version: 33 | - `@percy/cli` version: 34 | - Version of Percy SDK you’re using: 35 | - If needed, a build or snapshot ID: 36 | - OS version: 37 | - Type of shell command-line [interface]: 38 | 39 | ## Details 40 | 41 | If necessary, describe the problem you have been experiencing in more detail. 42 | 43 | ## Debug logs 44 | 45 | If you are reporting a bug, _always_ include logs! [Give the "Debugging SDKs" 46 | document a quick read for how to gather logs](https://www.browserstack.com/docs/percy/integrate/percy-sdk-workflow#debugging-sdks) 47 | 48 | Please do not trim or edit these logs, often times there are hints in the full 49 | logs that help debug what is going on. 50 | 51 | ## Code to reproduce issue 52 | 53 | Given the nature of testing/environment bugs, it’s best to try and isolate the 54 | issue in a reproducible repo. This will make it much easier for us to diagnose 55 | and fix. 56 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This must be required & started before any app code (for proper coverage) 2 | require 'simplecov' 3 | SimpleCov.start 4 | SimpleCov.minimum_coverage 100 5 | 6 | require 'capybara/rspec' 7 | require 'webmock/rspec' 8 | require 'percy/capybara' 9 | require 'selenium-webdriver' 10 | 11 | RSpec.configure do |config| 12 | config.expect_with :rspec do |expectations| 13 | # This option will default to `true` in RSpec 4. 14 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 15 | end 16 | 17 | config.mock_with :rspec do |mocks| 18 | mocks.verify_partial_doubles = true 19 | end 20 | 21 | config.disable_monkey_patching! 22 | # config.warnings = true 23 | 24 | # Run specs in random order to surface order dependencies. If you find an 25 | # order dependency and want to debug it, you can fix the order by providing 26 | # the seed, which is printed after each run. 27 | # --seed 1234 28 | config.order = :random 29 | 30 | # Seed global randomization in this process using the `--seed` CLI option. 31 | # Setting this allows you to use `--seed` to deterministically reproduce 32 | # test failures related to randomization by passing the same `--seed` value 33 | # as the one that triggered the failure. 34 | Kernel.srand config.seed 35 | 36 | # See https://github.com/teamcapybara/capybara#selecting-the-driver for other options 37 | Capybara.default_driver = :selenium_headless 38 | Capybara.javascript_driver = :selenium_headless 39 | 40 | # Setup for Capybara to test static files served by Rack 41 | Capybara.server_port = 3003 42 | Capybara.server = :puma, { Silent: true } 43 | Capybara.app = Rack::File.new(File.join(File.dirname(__FILE__), 'fixture')) 44 | 45 | # Ignore warning until Capybara releases an update solving their warning 46 | Selenium::WebDriver.logger.ignore(:browser_options) 47 | end 48 | 49 | ## Add cache clearing methods for tests 50 | Capybara::Session.class_eval { 51 | def __percy_clear_cache! 52 | @percy_dom = nil 53 | @percy_enabled = nil 54 | end 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | workflow_dispatch: 5 | inputs: 6 | branch: 7 | required: false 8 | type: string 9 | default: master 10 | jobs: 11 | test: 12 | name: Test 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] 16 | ruby: ['2.6', '2.7'] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions-ecosystem/action-regex-match@v2 20 | id: regex-match 21 | if: ${{ github.event_name == 'workflow_dispatch' }} 22 | with: 23 | text: ${{ github.event.inputs.branch }} 24 | regex: '^[a-zA-Z0-9_/\-]+$' 25 | - name: Break on invalid branch name 26 | run: exit 1 27 | if: ${{ github.event_name == 'workflow_dispatch' && steps.regex-match.outputs && steps.regex-match.outputs.match == '' }} 28 | - uses: actions/checkout@v3 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{matrix.ruby}} 32 | bundler-cache: true 33 | - uses: actions/cache@v3.0.11 34 | with: 35 | path: "./vendor/bundle" 36 | key: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}/${{ hashFiles('**/Gemfile.lock') }} 37 | restore-keys: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}/ 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: 16 41 | - name: Get yarn cache directory path 42 | id: yarn-cache-dir-path 43 | run: echo "::set-output name=dir::$(yarn cache dir)" 44 | - uses: actions/cache@v3 45 | with: 46 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 47 | key: v1/${{ runner.os }}/node-${{ matrix.node }}/${{ hashFiles('**/yarn.lock') }} 48 | restore-keys: v1/${{ runner.os }}/node-${{ matrix.node }}/ 49 | - run: yarn 50 | - name: Set up @percy/cli from git 51 | if: ${{ github.event_name == 'workflow_dispatch' }} 52 | run: | 53 | cd /tmp 54 | git clone --branch ${{ github.event.inputs.branch }} --depth 1 https://github.com/percy/cli 55 | cd cli 56 | PERCY_PACKAGES=`find packages -mindepth 1 -maxdepth 1 -type d | sed -e 's/packages/@percy/g' | tr '\n' ' '` 57 | git log -1 58 | yarn 59 | yarn build 60 | yarn global:link 61 | cd ${{ github.workspace }} 62 | yarn remove @percy/cli && yarn link `echo $PERCY_PACKAGES` 63 | npx percy --version 64 | - run: npx percy exec --testing -- bundle exec rspec 65 | -------------------------------------------------------------------------------- /lib/percy/capybara.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'uri' 3 | require 'capybara/dsl' 4 | require_relative './version' 5 | 6 | module PercyCapybara 7 | CLIENT_INFO = "percy-capybara/#{VERSION}".freeze 8 | ENV_INFO = "capybara/#{Capybara::VERSION} ruby/#{RUBY_VERSION}".freeze 9 | 10 | PERCY_DEBUG = ENV['PERCY_LOGLEVEL'] == 'debug' 11 | PERCY_SERVER_ADDRESS = ENV['PERCY_SERVER_ADDRESS'] || 'http://localhost:5338' 12 | PERCY_LABEL = "[\u001b[35m" + (PERCY_DEBUG ? 'percy:capybara' : 'percy') + "\u001b[39m]" 13 | 14 | private_constant :CLIENT_INFO 15 | private_constant :ENV_INFO 16 | 17 | # Take a DOM snapshot and post it to the snapshot endpoint 18 | def percy_snapshot(name, options = {}) 19 | return unless percy_enabled? 20 | 21 | page = Capybara.current_session 22 | 23 | begin 24 | page.evaluate_script(fetch_percy_dom) 25 | dom_snapshot = page 26 | .evaluate_script("(function() { return PercyDOM.serialize(#{options.to_json}) })()") 27 | 28 | response = fetch('percy/snapshot', 29 | name: name, 30 | url: page.current_url, 31 | dom_snapshot: dom_snapshot, 32 | client_info: CLIENT_INFO, 33 | environment_info: ENV_INFO, 34 | **options,) 35 | 36 | unless response.body.to_json['success'] 37 | raise StandardError, data['error'] 38 | end 39 | rescue StandardError => e 40 | log("Could not take DOM snapshot '#{name}'") 41 | 42 | if PERCY_DEBUG then log(e) end 43 | end 44 | end 45 | 46 | # Determine if the Percy server is running, caching the result so it is only checked once 47 | private def percy_enabled? 48 | return @percy_enabled unless @percy_enabled.nil? 49 | 50 | begin 51 | response = fetch('percy/healthcheck') 52 | version = response['x-percy-core-version'] 53 | 54 | if version.nil? 55 | log('You may be using @percy/agent ' \ 56 | 'which is no longer supported by this SDK. ' \ 57 | 'Please uninstall @percy/agent and install @percy/cli instead. ' \ 58 | 'https://www.browserstack.com/docs/percy/migration/migrate-to-cli') 59 | @percy_enabled = false 60 | return false 61 | end 62 | 63 | if version.split('.')[0] != '1' 64 | log("Unsupported Percy CLI version, #{version}") 65 | @percy_enabled = false 66 | return false 67 | end 68 | 69 | @percy_enabled = true 70 | true 71 | rescue StandardError => e 72 | log('Percy is not running, disabling snapshots') 73 | 74 | if PERCY_DEBUG then log(e) end 75 | @percy_enabled = false 76 | false 77 | end 78 | end 79 | 80 | # Fetch the @percy/dom script, caching the result so it is only fetched once 81 | private def fetch_percy_dom 82 | return @percy_dom unless @percy_dom.nil? 83 | 84 | response = fetch('percy/dom.js') 85 | @percy_dom = response.body 86 | end 87 | 88 | private def log(msg) 89 | puts "#{PERCY_LABEL} #{msg}" 90 | end 91 | 92 | # Make an HTTP request (GET,POST) using Ruby's Net::HTTP. If `data` is present, 93 | # `fetch` will POST as JSON. 94 | private def fetch(url, data = nil) 95 | uri = URI("#{PERCY_SERVER_ADDRESS}/#{url}") 96 | 97 | response = if data 98 | Net::HTTP.post(uri, data.to_json) 99 | else 100 | Net::HTTP.get_response(uri) 101 | end 102 | 103 | unless response.is_a? Net::HTTPSuccess 104 | raise StandardError, "Failed with HTTP error code: #{response.code}" 105 | end 106 | 107 | response 108 | end 109 | end 110 | 111 | # Add the `percy_snapshot` method to the the Capybara session class 112 | # `page.percy_snapshot('name', { options })` 113 | Capybara::Session.class_eval { include PercyCapybara } 114 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | percy-capybara (5.0.0) 5 | capybara (>= 3) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | addressable (2.8.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | archive-zip (0.12.0) 13 | io-like (~> 0.3.0) 14 | ast (2.4.2) 15 | capybara (3.36.0) 16 | addressable 17 | matrix 18 | mini_mime (>= 0.1.3) 19 | nokogiri (~> 1.8) 20 | rack (>= 1.6.0) 21 | rack-test (>= 0.6.3) 22 | regexp_parser (>= 1.5, < 3.0) 23 | xpath (~> 3.2) 24 | childprocess (4.1.0) 25 | coderay (1.1.3) 26 | crack (0.4.5) 27 | rexml 28 | diff-lcs (1.5.0) 29 | docile (1.4.0) 30 | ffi (1.15.5) 31 | formatador (1.1.0) 32 | geckodriver-bin (0.28.0) 33 | archive-zip (~> 0.7) 34 | guard (2.18.0) 35 | formatador (>= 0.2.4) 36 | listen (>= 2.7, < 4.0) 37 | lumberjack (>= 1.0.12, < 2.0) 38 | nenv (~> 0.1) 39 | notiffany (~> 0.0) 40 | pry (>= 0.13.0) 41 | shellany (~> 0.0) 42 | thor (>= 0.18.1) 43 | guard-compat (1.2.1) 44 | guard-rspec (4.7.3) 45 | guard (~> 2.1) 46 | guard-compat (~> 1.1) 47 | rspec (>= 2.99.0, < 4.0) 48 | hashdiff (1.0.1) 49 | io-like (0.3.1) 50 | jaro_winkler (1.5.4) 51 | listen (3.7.1) 52 | rb-fsevent (~> 0.10, >= 0.10.3) 53 | rb-inotify (~> 0.9, >= 0.9.10) 54 | lumberjack (1.2.8) 55 | matrix (0.4.2) 56 | method_source (1.0.0) 57 | mini_mime (1.1.2) 58 | mini_portile2 (2.8.0) 59 | nenv (0.3.0) 60 | nio4r (2.5.2) 61 | nokogiri (1.13.3) 62 | mini_portile2 (~> 2.8.0) 63 | racc (~> 1.4) 64 | notiffany (0.1.3) 65 | nenv (~> 0.1) 66 | shellany (~> 0.0) 67 | parallel (1.22.1) 68 | parser (3.1.1.0) 69 | ast (~> 2.4.1) 70 | percy-style (0.7.0) 71 | rubocop (~> 0.77.0) 72 | rubocop-rspec (~> 1.37.0) 73 | pry (0.14.1) 74 | coderay (~> 1.1) 75 | method_source (~> 1.0) 76 | public_suffix (4.0.6) 77 | puma (5.6.6) 78 | nio4r (~> 2.0) 79 | racc (1.6.0) 80 | rack (2.2.3) 81 | rack-test (1.1.0) 82 | rack (>= 1.0, < 3) 83 | rainbow (3.1.1) 84 | rake (13.0.6) 85 | rb-fsevent (0.11.1) 86 | rb-inotify (0.10.1) 87 | ffi (~> 1.0) 88 | regexp_parser (2.3.0) 89 | rexml (3.2.5) 90 | rspec (3.11.0) 91 | rspec-core (~> 3.11.0) 92 | rspec-expectations (~> 3.11.0) 93 | rspec-mocks (~> 3.11.0) 94 | rspec-core (3.11.0) 95 | rspec-support (~> 3.11.0) 96 | rspec-expectations (3.11.0) 97 | diff-lcs (>= 1.2.0, < 2.0) 98 | rspec-support (~> 3.11.0) 99 | rspec-mocks (3.11.1) 100 | diff-lcs (>= 1.2.0, < 2.0) 101 | rspec-support (~> 3.11.0) 102 | rspec-support (3.11.0) 103 | rubocop (0.77.0) 104 | jaro_winkler (~> 1.5.1) 105 | parallel (~> 1.10) 106 | parser (>= 2.6) 107 | rainbow (>= 2.2.2, < 4.0) 108 | ruby-progressbar (~> 1.7) 109 | unicode-display_width (>= 1.4.0, < 1.7) 110 | rubocop-rspec (1.37.1) 111 | rubocop (>= 0.68.1) 112 | ruby-progressbar (1.11.0) 113 | rubyzip (2.3.2) 114 | selenium-webdriver (4.1.0) 115 | childprocess (>= 0.5, < 5.0) 116 | rexml (~> 3.2, >= 3.2.5) 117 | rubyzip (>= 1.2.2) 118 | shellany (0.0.1) 119 | simplecov (0.21.2) 120 | docile (~> 1.1) 121 | simplecov-html (~> 0.11) 122 | simplecov_json_formatter (~> 0.1) 123 | simplecov-html (0.12.3) 124 | simplecov_json_formatter (0.1.4) 125 | thor (1.2.1) 126 | unicode-display_width (1.6.1) 127 | webmock (3.14.0) 128 | addressable (>= 2.8.0) 129 | crack (>= 0.3.2) 130 | hashdiff (>= 0.4.0, < 2.0.0) 131 | xpath (3.2.0) 132 | nokogiri (~> 1.8) 133 | 134 | PLATFORMS 135 | ruby 136 | 137 | DEPENDENCIES 138 | bundler (>= 2.0) 139 | capybara (>= 3) 140 | geckodriver-bin (~> 0.28.0) 141 | guard-rspec 142 | percy-capybara! 143 | percy-style (~> 0.7.0) 144 | pry 145 | puma (= 5.6.6) 146 | rake (~> 13.0) 147 | rspec (~> 3.5) 148 | selenium-webdriver (>= 4.0.0) 149 | simplecov 150 | webmock 151 | 152 | BUNDLED WITH 153 | 2.1.4 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # percy-capybara 2 | [![Gem Version](https://badge.fury.io/rb/percy-capybara.svg)](https://badge.fury.io/rb/percy-capybara) 3 | ![Test](https://github.com/percy/percy-capybara/workflows/Test/badge.svg) 4 | 5 | [Percy](https://percy.io) visual testing for Ruby Selenium. 6 | 7 | ## Installation 8 | 9 | npm install `@percy/cli`: 10 | 11 | ```sh-session 12 | $ npm install --save-dev @percy/cli 13 | ``` 14 | 15 | gem install `percy-capybara` package: 16 | 17 | ```ssh-session 18 | $ gem install percy-capybara 19 | ``` 20 | 21 | ## Usage 22 | 23 | In your test setup file, require `percy/capybara`. For example if you're using 24 | rspec, you would add the following to your `spec_helper.rb` file: 25 | 26 | ``` ruby 27 | require 'percy/capybara' 28 | ``` 29 | 30 | Now you can use `page.percy_snapshot` to capture snapshots. 31 | 32 | > Note: you may need to add `js: true` to your specs, depending on your driver setup 33 | 34 | ```ruby 35 | describe 'my feature, type: :feature do 36 | it 'renders the page' do 37 | visit 'https://example.com' 38 | page.percy_snapshot('Capybara snapshot') 39 | end 40 | end 41 | ``` 42 | 43 | Running the test above normally will result in the following log: 44 | 45 | ```sh-session 46 | [percy] Percy is not running, disabling snapshots 47 | ``` 48 | 49 | When running with [`percy 50 | exec`](https://github.com/percy/cli/tree/master/packages/cli-exec#percy-exec), and your project's 51 | `PERCY_TOKEN`, a new Percy build will be created and snapshots will be uploaded to your project. 52 | 53 | ```sh-session 54 | $ export PERCY_TOKEN=[your-project-token] 55 | $ percy exec -- [test command] 56 | [percy] Percy has started! 57 | [percy] Created build #1: https://percy.io/[your-project] 58 | [percy] Snapshot taken "Capybara example" 59 | [percy] Stopping percy... 60 | [percy] Finalized build #1: https://percy.io/[your-project] 61 | [percy] Done! 62 | ``` 63 | 64 | ## Configuration 65 | 66 | `page.snapshot(name[, options])` 67 | 68 | - `name` (**required**) - The snapshot name; must be unique to each snapshot 69 | - `options` - [See per-snapshot configuration options](https://www.browserstack.com/docs/percy/take-percy-snapshots/overview#per-snapshot-configuration) 70 | 71 | ## Upgrading 72 | 73 | ### Automatically with `@percy/migrate` 74 | 75 | We built a tool to help automate migrating to the new CLI toolchain! Migrating 76 | can be done by running the following commands and following the prompts: 77 | 78 | ``` shell 79 | $ npx @percy/migrate 80 | ? Are you currently using percy-capybara? Yes 81 | ? Install @percy/cli (required to run percy)? Yes 82 | ? Migrate Percy config file? Yes 83 | ? Upgrade SDK to percy-capybara@^5.0.0? Yes 84 | ? The Capybara API has breaking changes, automatically convert to the new API? Yes 85 | ``` 86 | 87 | This will automatically run the changes described below for you, with the 88 | exception of changing the `require`. 89 | 90 | ### Manually 91 | 92 | #### Require change 93 | 94 | The name of the require has changed from `require 'percy'` to `require 95 | 'percy/capybara'`. This is to avoid conflict with our [Ruby Selenium SDK's](https://github.com/percy/percy-selenium-ruby) 96 | require statement. 97 | 98 | #### API change 99 | 100 | The previous version of this SDK had the following function signature: 101 | 102 | ``` ruby 103 | Percy.snapshot(driver, name, options) 104 | ``` 105 | 106 | v5.x of this SDK has a significant change to the API. There no longer is a stand 107 | alone module to call and you no longer need to pass the page/driver. It's 108 | available on the current Capybara session (`page`): 109 | 110 | ``` ruby 111 | page.percy_snapshot(name, options) 112 | ``` 113 | 114 | If you were using this SDK outside of Capybara, you'll likely find the [Ruby 115 | Selenium SDK a better fit](https://github.com/percy/percy-selenium-ruby) 116 | 117 | #### Installing `@percy/cli` & removing `@percy/agent` 118 | 119 | If you're coming from a 4.x version of this package, make sure to install `@percy/cli` after 120 | upgrading to retain any existing scripts that reference the Percy CLI 121 | command. You will also want to uninstall `@percy/agent`, as it's been replaced 122 | by `@percy/cli`. 123 | 124 | ```sh-session 125 | $ npm uninstall @percy/agent 126 | $ npm install --save-dev @percy/cli 127 | ``` 128 | 129 | #### Migrating config 130 | 131 | If you have a previous Percy configuration file, migrate it to the newest version with the 132 | [`config:migrate`](https://github.com/percy/cli/tree/master/packages/cli-config#percy-configmigrate-filepath-output) command: 133 | 134 | ```sh-session 135 | $ percy config:migrate 136 | ``` 137 | -------------------------------------------------------------------------------- /spec/lib/percy/percy_capybara_spec.rb: -------------------------------------------------------------------------------- 1 | LABEL = PercyCapybara::PERCY_LABEL 2 | 3 | # rubocop:disable RSpec/MultipleDescribes 4 | RSpec.describe PercyCapybara, type: :feature do 5 | before(:each) do 6 | WebMock.disable_net_connect!(allow: '127.0.0.1', disallow: 'localhost') 7 | page.__percy_clear_cache! 8 | end 9 | 10 | describe 'snapshot', type: :feature do 11 | it 'disables when healthcheck version is incorrect' do 12 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 13 | .to_return(status: 200, body: '', headers: {'x-percy-core-version': '0.1.0'}) 14 | 15 | expect { page.percy_snapshot('Name') } 16 | .to output("#{LABEL} Unsupported Percy CLI version, 0.1.0\n").to_stdout 17 | end 18 | 19 | it 'disables when healthcheck version is missing' do 20 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 21 | .to_return(status: 200, body: '', headers: {}) 22 | 23 | expect { page.percy_snapshot('Name') } 24 | .to output( 25 | "#{LABEL} You may be using @percy/agent which" \ 26 | ' is no longer supported by this SDK. Please uninstall' \ 27 | ' @percy/agent and install @percy/cli instead.' \ 28 | " https://www.browserstack.com/docs/percy/migration/migrate-to-cli\n", 29 | ).to_stdout 30 | end 31 | 32 | it 'disables when healthcheck fails' do 33 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 34 | .to_return(status: 500, body: '', headers: {}) 35 | 36 | expect { page.percy_snapshot('Name') } 37 | .to output("#{LABEL} Percy is not running, disabling snapshots\n").to_stdout 38 | end 39 | 40 | it 'disables when healthcheck fails to connect' do 41 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 42 | .to_raise(StandardError) 43 | 44 | expect { page.percy_snapshot('Name') } 45 | .to output("#{LABEL} Percy is not running, disabling snapshots\n").to_stdout 46 | end 47 | 48 | it 'throws an error when name is not provided' do 49 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 50 | .to_return(status: 500, body: '', headers: {}) 51 | 52 | expect { page.percy_snapshot }.to raise_error(ArgumentError) 53 | end 54 | 55 | it 'logs an error when sending a snapshot fails' do 56 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 57 | .to_return(status: 200, body: '', headers: {'x-percy-core-version': '1.0.0'}) 58 | 59 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/dom.js") 60 | .to_return( 61 | status: 200, 62 | body: 'window.PercyDOM = { serialize: () => document.documentElement.outerHTML };', 63 | headers: {}, 64 | ) 65 | 66 | stub_request(:post, 'http://localhost:5338/percy/snapshot') 67 | .to_return(status: 200, body: '', headers: {}) 68 | 69 | expect { page.percy_snapshot('Name') } 70 | .to output("#{LABEL} Could not take DOM snapshot 'Name'\n").to_stdout 71 | end 72 | 73 | it 'sends snapshots to the local server' do 74 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/healthcheck") 75 | .to_return(status: 200, body: '', headers: {'x-percy-core-version': '1.0.0'}) 76 | 77 | stub_request(:get, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/dom.js") 78 | .to_return( 79 | status: 200, 80 | body: 'window.PercyDOM = { serialize: () => document.documentElement.outerHTML };', 81 | headers: {}, 82 | ) 83 | 84 | stub_request(:post, 'http://localhost:5338/percy/snapshot') 85 | .to_return(status: 200, body: '{"success": "true" }', headers: {}) 86 | 87 | visit 'index.html' 88 | page.percy_snapshot('Name', widths: [375]) 89 | 90 | expect(WebMock) 91 | .to have_requested(:post, "#{PercyCapybara::PERCY_SERVER_ADDRESS}/percy/snapshot") 92 | .with( 93 | body: { 94 | name: 'Name', 95 | url: 'http://127.0.0.1:3003/index.html', 96 | dom_snapshot: 97 | "I am a pageSnapshot me\n", 98 | client_info: "percy-capybara/#{PercyCapybara::VERSION}", 99 | environment_info: "capybara/#{Capybara::VERSION} ruby/#{RUBY_VERSION}", 100 | widths: [375], 101 | }.to_json, 102 | ).once 103 | expect(page).to have_current_path('/index.html') 104 | end 105 | end 106 | end 107 | 108 | RSpec.describe PercyCapybara, type: :feature do 109 | before(:each) do 110 | WebMock.reset! 111 | WebMock.allow_net_connect! 112 | page.__percy_clear_cache! 113 | end 114 | 115 | describe 'integration', type: :feature do 116 | it 'sends snapshots to percy server' do 117 | visit 'index.html' 118 | page.percy_snapshot('Name', widths: [375]) 119 | sleep 5 # wait for percy server to process 120 | resp = Net::HTTP.get_response(URI("#{PercyCapybara::PERCY_SERVER_ADDRESS}/test/requests")) 121 | requests = JSON.parse(resp.body)['requests'] 122 | healthcheck = requests[0] 123 | expect(healthcheck['url']).to eq('/percy/healthcheck') 124 | 125 | snap = requests[2]['body'] 126 | expect(snap['name']).to eq('Name') 127 | expect(snap['url']).to eq('http://127.0.0.1:3003/index.html') 128 | expect(snap['client_info']).to include('percy-capybara') 129 | expect(snap['environment_info']).to include('capybara') 130 | expect(snap['widths']).to eq([375]) 131 | end 132 | end 133 | end 134 | # rubocop:enable RSpec/MultipleDescribes 135 | --------------------------------------------------------------------------------