├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── capybara-lockstep.gemspec ├── lib ├── capybara-lockstep.rb └── capybara-lockstep │ ├── capybara_ext.rb │ ├── client.rb │ ├── configuration.rb │ ├── errors.rb │ ├── helper.js │ ├── helper.rb │ ├── lockstep.rb │ ├── logging.rb │ ├── middleware.rb │ ├── page_access.rb │ ├── server.rb │ ├── util.rb │ └── version.rb ├── media ├── logo.dark.shapes.svg ├── logo.dark.text.svg ├── logo.light.shapes.svg ├── logo.light.text.svg ├── makandra-with-bottom-margin.dark.svg └── makandra-with-bottom-margin.light.svg └── spec ├── features ├── spec_spec.rb └── synchronization_spec.rb ├── fixtures ├── audio.mp3 ├── image.png └── video.mp4 ├── lib └── capybara-lockstep │ ├── capybara_ext_spec.rb │ ├── helper_spec.rb │ └── lockstep_spec.rb ├── spec_helper.rb └── support ├── app.rb ├── matchers ├── be_broken_image.rb ├── be_loaded_image.rb ├── be_media_element_with_metadata.rb ├── have_ready_state.rb └── run_into_wall.rb ├── observable_command.rb └── wall.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | branches: 12 | - main 13 | jobs: 14 | test: 15 | runs-on: ubuntu-20.04 16 | timeout-minutes: 3 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | include: 21 | - ruby: 2.7.2 22 | gemfile: Gemfile 23 | - ruby: 3.2.0 24 | gemfile: Gemfile 25 | - ruby: 3.4.1 26 | gemfile: Gemfile 27 | env: 28 | BUNDLE_GEMFILE: "${{ matrix.gemfile }}" 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Install Chrome 32 | uses: browser-actions/setup-chrome@latest 33 | - name: Show Chrome version 34 | run: chrome --version 35 | - name: Install ChromeDriver 36 | uses: nanasess/setup-chromedriver@master 37 | - name: Install ruby 38 | uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: "${{ matrix.ruby }}" 41 | - name: Bundle 42 | run: | 43 | gem install bundler:2.3.1 44 | bundle install --no-deployment 45 | - name: Run tests 46 | uses: nick-invision/retry@v2 47 | with: 48 | timeout_seconds: 30 49 | max_attempts: 3 50 | command: bundle exec rake spec 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /todo.txt 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .idea 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.1 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | All notable changes to this project will be documented in this file. 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 4 | 5 | # Unreleased changes 6 | 7 | ## Breaking changes 8 | 9 | - 10 | 11 | ## Compatible changes 12 | 13 | - 14 | 15 | 16 | # 2.2.3 17 | 18 | - Requiring the gem no longer force-loads ActionView (#22) 19 | - Calling `visit(nil)` visits the root route instead of crashing (#21) 20 | - Tested against Ruby 3.4 (in addition to 2.7 and 3.2) 21 | 22 | 23 | # 2.2.2 24 | 25 | - We now only wait for ` 20 | HTML 21 | end 22 | 23 | get '/next' do 24 | instance_exec(&next_action) 25 | end 26 | 27 | def self.reset 28 | self.start_html = 'hi world' 29 | self.start_script = 'console.log("loaded")' 30 | self.next_action = -> { 'hello from /next' } 31 | end 32 | 33 | private 34 | 35 | def render_body(content) 36 | <<~HTML 37 | 38 |
39 | 40 | 41 | 42 | #{content} 43 | 44 | 45 | HTML 46 | end 47 | 48 | def send_file_sync(path, mime_type) 49 | content_type mime_type 50 | File.read(path) 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /spec/support/matchers/be_broken_image.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_broken_image do 2 | 3 | match do |selector_or_element| 4 | if selector_or_element.is_a?(String) 5 | element = page.find(selector_or_element) 6 | else 7 | element = selector_or_element 8 | end 9 | 10 | is_broken = element.evaluate_script('this.complete && this.naturalWidth === 0') 11 | 12 | expect(is_broken).to eq(true) 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/matchers/be_loaded_image.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_loaded_image do 2 | 3 | match do |selector_or_element| 4 | if selector_or_element.is_a?(String) 5 | element = page.find(selector_or_element) 6 | else 7 | element = selector_or_element 8 | end 9 | 10 | # Cannot just use the #complete property, as this is also true for broken image 11 | is_loaded = element.evaluate_script('this.complete && this.naturalWidth > 0') 12 | 13 | expect(is_loaded).to eq(true) 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/matchers/be_media_element_with_metadata.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_media_element_with_metadata do 2 | 3 | match(notify_expectation_failures: true) do |selector_or_element| 4 | if selector_or_element.is_a?(String) 5 | element = page.find(selector_or_element) 6 | else 7 | element = selector_or_element 8 | end 9 | 10 | ready_state = element.evaluate_script('this.readyState') 11 | 12 | expect(ready_state).to be >= 1 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/matchers/have_ready_state.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :have_ready_state do |expected_ready_state| 2 | 3 | match(notify_expectation_failures: true) do |selector_or_element| 4 | if selector_or_element.is_a?(String) 5 | element = page.find(selector_or_element) 6 | else 7 | element = selector_or_element 8 | end 9 | 10 | ready_state = element.evaluate_script('this.readyState') 11 | 12 | expect(ready_state).to eq(expected_ready_state) 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/matchers/run_into_wall.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :run_into_wall do |wall| 2 | include RSpec::Wait 3 | 4 | match(notify_expectation_failures: true) do |command| 5 | expect(command).to be_initialized 6 | expect(wall).to_not be_blocking 7 | 8 | command.execute 9 | 10 | expect(command).to be_running 11 | wait(0.5.seconds).for { wall }.to be_blocking 12 | 13 | sleep(0.25.seconds) 14 | expect(command).to be_running 15 | expect(wall).to be_blocking 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/observable_command.rb: -------------------------------------------------------------------------------- 1 | class ObservableCommand 2 | 3 | def initialize(&block) 4 | @state = :initialized 5 | @block = block 6 | @error = nil 7 | end 8 | 9 | attr_reader :state, :error 10 | 11 | # Don't name it #call or expect() and wait() will automatically call it 12 | def execute 13 | @state = :running 14 | Thread.new do 15 | @block.call 16 | @state = :finished 17 | rescue Exception => error 18 | @error = error 19 | @state = :failed 20 | end 21 | end 22 | 23 | def initialized? 24 | state == :initialized 25 | end 26 | 27 | def running? 28 | state == :running 29 | end 30 | 31 | def finished? 32 | state == :finished 33 | end 34 | 35 | def failed? 36 | state == :failed? 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/wall.rb: -------------------------------------------------------------------------------- 1 | class Wall 2 | 3 | def initialize 4 | @queue = Queue.new 5 | @mutex = Mutex.new 6 | end 7 | 8 | def block 9 | self.class.blocking_walls.push(self) 10 | @queue.pop # this will block until #release pushses a value 11 | end 12 | 13 | def release 14 | self.class.blocking_walls.delete(self) 15 | @queue.push(:value) 16 | end 17 | 18 | delegate :num_waiting, to: :@queue 19 | 20 | def blocking? 21 | num_waiting > 0 22 | end 23 | 24 | def self.blocking_walls 25 | @blocking_walls ||= [] 26 | end 27 | 28 | end 29 | 30 | RSpec.configure do |config| 31 | config.after(:each) do 32 | Wall.blocking_walls.dup.each(&:release) 33 | end 34 | end 35 | --------------------------------------------------------------------------------