diff --git a/.github/Dockerfile.base b/.github/Dockerfile.base index 5fae1efe..39557d77 100644 --- a/.github/Dockerfile.base +++ b/.github/Dockerfile.base @@ -3,7 +3,7 @@ # call this from rails root: podman build -t mapforge-base -f .github/Dockerfile.base . # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.5 +ARG RUBY_VERSION=4.0.0 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base LABEL org.opencontainers.image.source="https://github.com/mapforge-org/mapforge" diff --git a/.rubocop.yml b/.rubocop.yml index 437af5cd..e8f37347 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,7 +5,7 @@ inherit_gem: inherit_from: .rubocop_todo.yml AllCops: - TargetRubyVersion: 3.4 + TargetRubyVersion: 4.0 DisplayCopNames: true DisplayStyleGuide: true ExtraDetails: true diff --git a/.ruby-version b/.ruby-version index 4f5e6973..fcdb2e10 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.5 +4.0.0 diff --git a/Gemfile b/Gemfile index 25ab22ad..2db917a1 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "3.4.5" +ruby "4.0.0" gem "rails" @@ -58,14 +58,13 @@ gem "rszr" gem "rgeo" gem "rgeo-geojson" gem "rgeo-proj4" -gem "gpx" +gem "gpx", git: "https://github.com/digitaltom/gpx" # Ruby 4.0 fork # resolving request IP addresses to coordinates gem "maxminddb" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "byebug" - gem "debug", platforms: %i[mri windows] + gem "debug" gem "dotenv-rails", require: "dotenv/load" gem "listen" gem "mongo_logs_on_roids" @@ -101,6 +100,7 @@ group :test do gem "simplecov" gem "database_cleaner-mongoid" gem "mongoid-rspec" - gem "puffing-billy" + gem "cuprite" + gem "capybara_mock" gem "table_print" end diff --git a/Gemfile.lock b/Gemfile.lock index 4cbd9bf1..6ebb6651 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +GIT + remote: https://github.com/digitaltom/gpx + revision: 204842fc2a00dca66adb66d7d8148e7580e197bd + specs: + gpx (1.2.1) + csv + nokogiri (~> 1.7) + rake + GIT remote: https://github.com/ruby/net-pop.git revision: 30d89b359c940610d84ecd1fed0dba4003508fde @@ -104,7 +113,6 @@ GEM bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) - byebug (12.0.0) capybara (3.40.0) addressable matrix @@ -117,15 +125,19 @@ GEM capybara-screenshot (1.0.26) capybara (>= 1.0, < 4) launchy + capybara_mock (0.2.0) + rack (>= 2.2.0) childprocess (5.1.0) logger (~> 1.5) coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.3.6) connection_pool (3.0.2) - cookiejar (0.3.4) crass (1.0.6) - csv (3.3.3) + csv (3.3.5) + cuprite (0.17) + capybara (~> 3.0) + ferrum (~> 0.17.0) database_cleaner-core (2.0.1) database_cleaner-mongoid (2.0.1) database_cleaner-core (~> 2.0.0) @@ -177,21 +189,8 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) - em-http-request (1.1.7) - addressable (>= 2.3.4) - cookiejar (!= 0.3.1) - em-socksify (>= 0.3) - eventmachine (>= 1.0.3) - http_parser.rb (>= 0.6.0) - em-socksify (0.3.3) - base64 - eventmachine (>= 1.0.0.beta.4) - em-synchrony (1.0.6) - eventmachine (>= 1.0.0.beta.1) erb (6.0.1) erubi (1.13.1) - eventmachine (1.2.7) - eventmachine_httpserver (0.2.1) factory_bot (6.5.5) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) @@ -203,6 +202,12 @@ GEM logger faraday-net_http (3.4.0) net-http (>= 0.5.0) + ferrum (0.17.1) + addressable (~> 2.5) + base64 (~> 0.2) + concurrent-ruby (~> 1.1) + webrick (~> 1.7) + websocket-driver (~> 0.7) ffi (1.17.0) flay (2.13.3) erubi (~> 1.10) @@ -220,10 +225,6 @@ GEM i18n (>= 0.7) multi_json request_store (>= 1.0) - gpx (1.2.1) - csv - nokogiri (~> 1.7) - rake haml (7.1.0) temple (>= 0.8.2) thor @@ -233,7 +234,6 @@ GEM listen rails (>= 7.0.0) zeitwerk - http_parser.rb (0.8.0) i18n (1.14.7) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -319,7 +319,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.18.10-x86_64-linux-gnu) + nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) oauth2 (2.0.9) faraday (>= 0.17.3, < 3.0) @@ -362,14 +362,6 @@ GEM date stringio public_suffix (6.0.2) - puffing-billy (4.0.2) - addressable (~> 2.5) - em-http-request (~> 1.1, >= 1.1.0) - em-synchrony - eventmachine (~> 1.2) - eventmachine_httpserver - http_parser.rb (~> 0.8.0) - multi_json puma (7.1.0) nio4r (~> 2.0) puppeteer-ruby (0.45.6) @@ -520,7 +512,7 @@ GEM rubocop-ast (>= 1.44.0, < 2.0) ruby-next-core (1.1.2) ruby-progressbar (1.13.0) - ruby_parser (3.21.1) + ruby_parser (3.22.0) racc (~> 1.5) sexp_processor (~> 4.16) rubycritic (4.11.0) @@ -543,7 +535,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sexp_processor (4.17.4) + sexp_processor (4.17.5) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -592,6 +584,7 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webrick (1.9.2) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -626,16 +619,17 @@ DEPENDENCIES bootsnap brakeman bundler-audit - byebug capybara capybara-screenshot + capybara_mock + cuprite database_cleaner-mongoid debug dotenv-rails dragonfly factory_bot_rails gon - gpx + gpx! haml hotwire-spark importmap-rails @@ -654,7 +648,6 @@ DEPENDENCIES omniauth-google-oauth2 ostruct parallel_tests - puffing-billy puma puppeteer-ruby rack-cache @@ -690,7 +683,7 @@ DEPENDENCIES yabeda-rails RUBY VERSION - ruby 3.4.5 + ruby 4.0.0p0 BUNDLED WITH - 2.7.1 + 4.0.3 diff --git a/app/channels/map_channel.rb b/app/channels/map_channel.rb index 31e33dd6..7454e9ec 100644 --- a/app/channels/map_channel.rb +++ b/app/channels/map_channel.rb @@ -3,7 +3,9 @@ class MapChannel < ApplicationCable::Channel # Check auth on update methods by looking up map with private id def subscribed super - Map.find_by(private_id: params[:map_id]) || Map.find_by(public_id: params[:map_id]) + map = Map.find_by(private_id: params[:map_id]) || Map.find_by(public_id: params[:map_id]) + Rails.logger.warn "Invalid map id #{params[:map_id]} for subscribing to channel" and return unless map + stream_from "map_channel_#{params[:map_id]}" transmit({ event: "connection", uuid: uuid }) Rails.logger.debug { "MapChannel subscribed '#{uuid}' for '#{params[:map_id]}'" } diff --git a/spec/features/feature_details_spec.rb b/spec/features/feature_details_spec.rb index 528a1b0c..00756aac 100644 --- a/spec/features/feature_details_spec.rb +++ b/spec/features/feature_details_spec.rb @@ -8,22 +8,34 @@ expect_map_loaded end - context 'with polygon on map' do + context 'mobile', :mobile do before { create(:feature, :polygon_middle, layer: map.layers.first, title: 'Poly Title') } context 'with selected feature' do before do - click_coord('#maplibre-map', 50, 50) + click_center_of_screen expect(page).to have_css('#feature-details-modal') end - it 'can enlarge modal with pull-up button', :mobile do - height = find('#feature-details-modal').native.style('height').sub('px', '').to_i - expect(height).to be < 200 + it 'can enlarge modal with pull-up button' do + height = element_offset_height('#feature-details-modal') + # initial height is half the screen height + expect(height).to be < 300 find('.modal-pull-button').click sleep(0.3) - height = find('#feature-details-modal').native.style('height').sub('px', '').to_i - expect(height).to be > 150 + height = element_offset_height('#feature-details-modal') + expect(height).to be > 300 + end + end + end + + context 'export' do + before { create(:feature, :polygon_middle, layer: map.layers.first, title: 'Poly Title') } + + context 'with selected feature' do + before do + click_center_of_screen + expect(page).to have_css('#feature-details-modal') end it 'can download feature export' do diff --git a/spec/features/feature_directions_spec.rb b/spec/features/feature_directions_spec.rb index 3a211147..3553c7d4 100644 --- a/spec/features/feature_directions_spec.rb +++ b/spec/features/feature_directions_spec.rb @@ -12,8 +12,8 @@ it 'can create foot track' do find('.mapbox-gl-draw_line').click find('.mapbox-gl-draw_foot').click - click_coord('#maplibre-map', 50, 50) - click_coord('#maplibre-map', 150, 150) + click_coord('#maplibre-map', 250, 250) + click_coord('#maplibre-map', 450, 450) wait_for { Feature.line_string.count }.to eq(1) end end diff --git a/spec/features/feature_edit_spec.rb b/spec/features/feature_edit_spec.rb index 56dda8ac..c45aa249 100644 --- a/spec/features/feature_edit_spec.rb +++ b/spec/features/feature_edit_spec.rb @@ -57,7 +57,7 @@ context 'with selected polygon feature' do before do - click_coord('#maplibre-map', 50, 50) + click_coord('#maplibre-map', 512, 430) expect(page).to have_css('#edit-button-edit') end @@ -101,7 +101,7 @@ context 'with selected point feature' do before do - click_coord('#maplibre-map', 50, 50) + click_coord('#maplibre-map', 512, 430) find('#edit-button-edit').click end @@ -134,8 +134,10 @@ end it 'can update fill color' do - find('#fill-color').set('#aabbcc') - wait_for { point.reload.properties['marker-color'] }.to eq('#aabbcc') + color = '#aa00cc' + set_color_input('#fill-color', color) + + wait_for { point.reload.properties['marker-color'] }.to eq(color) end it 'can set fill color transparent' do @@ -151,8 +153,10 @@ end it 'can update outline color' do - find('#stroke-color').set('#aabbcc') - wait_for { point.reload.properties['stroke'] }.to eq('#aabbcc') + color = '#aa00cc' + set_color_input('#stroke-color', color) + + wait_for { point.reload.properties['stroke'] }.to eq(color) end it 'can upload image' do diff --git a/spec/features/keyboard_shortcuts_spec.rb b/spec/features/keyboard_shortcuts_spec.rb index 14de1b3e..f5455809 100644 --- a/spec/features/keyboard_shortcuts_spec.rb +++ b/spec/features/keyboard_shortcuts_spec.rb @@ -16,12 +16,13 @@ context 'with selected point feature' do before do - click_coord('#maplibre-map', 50, 50) + click_center_of_screen expect(page).to have_text('Point Title') end it 'can copy & paste point' do - find('body').send_keys(:control, 'c') + page.driver.browser.keyboard.type([ :Control, "c" ]) + expect(page).to have_text('Feature copied to clipboard') # Clipboard read is disallowed in headless mode diff --git a/spec/features/map_layers_spec.rb b/spec/features/map_layers_spec.rb index 1586eea2..1624a3af 100644 --- a/spec/features/map_layers_spec.rb +++ b/spec/features/map_layers_spec.rb @@ -80,10 +80,15 @@ context 'overpass layer' do before do - proxy.stub('https://overpass-api.de:443/api/interpreter', method: 'post') - .and_return( - headers: { 'Access-Control-Allow-Origin' => '*' }, - text: File.read(Rails.root.join("spec", "fixtures", "files", "overpass.json"))) + overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass.json")) + # https://github.com/railsware/capybara_mock + CapybaraMock.stub_request( + :post, 'https://overpass-api.de/api/interpreter' + ).to_return( + headers: { 'Access-Control-Allow-Origin' => '*' }, + status: 200, + body: overpass_file + ) map.layers << layer visit map.private_map_path @@ -95,7 +100,7 @@ let(:layer) { create(:layer, :overpass, name: 'opass') } it 'Shows overpass layer' do - expect(page).to have_text('opass') + expect(page).to have_text('opass(1)') end it 'can add overpass layer' do diff --git a/spec/features/map_view_spec.rb b/spec/features/map_view_spec.rb index a8a65548..83544536 100644 --- a/spec/features/map_view_spec.rb +++ b/spec/features/map_view_spec.rb @@ -27,36 +27,38 @@ let(:map) { create(:map, features: [ polygon ]) } it 'shows feature details on hover' do - # coordinates are calculated from the center middle - hover_coord('.map', 50, 50) + hover_center_of_screen + expect(page).to have_css('#feature-details-modal') expect(page).to have_text('Poly Title') expect(page).to have_text('Poly Desc') end it 'feature details are not sticky on hover' do - hover_coord('.map', 50, 50) + hover_center_of_screen expect(page).to have_text('Poly Title') - hover_coord('.map', 400, 0) + center = center_of_screen + page.driver.browser.mouse.move(x: center[:x] + 400, y: center[:y]) + expect(page).not_to have_text('Poly Desc') end it 'shows feature details on click' do - click_coord('.map', 50, 50) + click_center_of_screen expect(page).to have_css('#feature-details-modal') expect(page).to have_text('Poly Title') expect(page).to have_text('Poly Desc') end it 'updates url on feature select' do - click_coord('.map', 50, 50) + click_center_of_screen expect(page).to have_current_path("/m/#{map.public_id}?f=#{polygon.id}") end it 'feature details are sticky on click' do - click_coord('.map', 50, 50) + click_center_of_screen expect(page).to have_text('Poly Desc') - hover_coord('.map', 400, 0) + hover_coord(400, 0) expect(page).to have_text('Poly Desc') click_coord('.map', 400, 0) expect(page).not_to have_text('Poly Desc') @@ -83,10 +85,12 @@ context 'with features that don\'t have properties' do # this polygon is in the middle of nbg (default view) - before { create(:feature, :polygon_middle, layer: map.layers.first, properties: nil) } + let(:polygon) { create(:feature, :polygon_middle, properties: nil) } + let(:map) { create(:map, features: [ polygon ]) } + it 'shows feature details on hover' do - hover_coord('.map', 50, 50) + hover_center_of_screen expect(page).to have_css('#feature-details-modal') end end @@ -95,7 +99,7 @@ # feature is created after loading the map, to make sure it's loaded via websocket it 'receives new features via websocket channel' do create(:feature, :polygon_middle, layer: map.layers.first, title: 'New Title') - click_coord('.map', 50, 50) + click_center_of_screen expect(page).to have_css('#feature-details-modal') expect(page).to have_text('New Title') end @@ -130,11 +134,16 @@ it 'catches up with new features on reconnect' do go_offline + expect(page).to have_css('div:not(.hidden):has(> button.maplibregl-ctrl-connection)') + expect(page).to have_css("#maplibre-map[data-online='false']") + create(:feature, :polygon_middle, layer: map.layers.geojson.first, title: 'Poly Title') go_online + expect_map_loaded - click_coord('#maplibre-map', 50, 50) + sleep 1 # give some time for the feature to be received + click_center_of_screen expect(page).to have_text('Poly Title') end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 7d9c8367..297235f0 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -19,6 +19,7 @@ require 'database_cleaner/mongoid' require 'capybara-screenshot/rspec' require 'mongoid-rspec' +require 'capybara_mock/rspec' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -40,7 +41,6 @@ config.use_active_record = false config.include Mongoid::Matchers, type: :model - config.include Features::Helpers, type: :feature config.include FactoryBot::Syntax::Methods config.before(:suite) do @@ -56,37 +56,36 @@ end config.around(:each, :mobile) do |spec| - browser = Capybara.current_session.driver.browser - browser.manage.window.resize_to(290, 523) + page.driver.browser.resize(width: 290, height: 523) spec.run - browser.manage.window.resize_to(1024, 576) + page.driver.browser.resize(width: 1024, height: 860) end # raise on js console errors class JavaScriptError< StandardError; end - RSpec.configure do |config| - config.after(:each, type: :feature) do |spec| - unless spec.metadata[:skip_console_errors] - levels = [ "SEVERE" ] - # "maplibre-gl.js TypeError: Failed to fetch" seems to be caused by - # the js file being cached already - exclude = [ /TypeError: Failed to fetch/, - /The user aborted a request/, - /Failed to load resource/ ] - errors = page.driver.browser.logs.get(:browser).to_a - .select { |e| levels.include?(e.level) && e.message.present? } - .reject { |e| exclude.any? { |ex| e.message =~ ex } } - .map(&:message) - if errors.present? - raise JavaScriptError, errors.join("\n\n") - end - end - if spec.metadata[:print_console_logs] - logs = page.driver.browser.logs.get(:browser).to_a.map(&:message) - puts logs.join("\n\n") - end - end - end + # RSpec.configure do |config| + # config.after(:each, type: :feature) do |spec| + # unless spec.metadata[:skip_console_errors] + # levels = [ "SEVERE" ] + # # "maplibre-gl.js TypeError: Failed to fetch" seems to be caused by + # # the js file being cached already + # exclude = [ /TypeError: Failed to fetch/, + # /The user aborted a request/, + # /Failed to load resource/ ] + # errors = page.driver.browser.logs.get(:browser).to_a + # .select { |e| levels.include?(e.level) && e.message.present? } + # .reject { |e| exclude.any? { |ex| e.message =~ ex } } + # .map(&:message) + # if errors.present? + # raise JavaScriptError, errors.join("\n\n") + # end + # end + # if spec.metadata[:print_console_logs] + # logs = page.driver.browser.logs.get(:browser).to_a.map(&:message) + # puts logs.join("\n\n") + # end + # end + # end # If you enable ActiveRecord support you should uncomment these lines, # note if you'd prefer not to run each example within a transaction, you diff --git a/spec/support/billy.rb b/spec/support/billy.rb deleted file mode 100644 index ab787c47..00000000 --- a/spec/support/billy.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'table_print' # Add this dependency to your gemfile - -# https://github.com/oesmith/puffing-billy?tab=readme-ov-file#params -Billy.configure do |c| - # c.record_requests = true # needed for the table output below - c.non_successful_error_level = :error - c.cache = true - c.persist_cache = true - c.cache_path = 'tmp/billy_req_cache/' -end - -# RSpec.configure do |config| -# config.prepend_after(:example, type: :feature) do -# puts "Requests received via Puffing Billy Proxy:" - -# puts TablePrint::Printer.table_print(Billy.proxy.requests, [ -# :status, -# :handler, -# :method, -# { url: { width: 100 } }, -# :headers, -# :body -# ]) -# end -# end - -# RSpec.configure do |config| -# config.prepend_before(:suite) do -# if defined?(Billy) -# local_cache_path = Rails.root.join(Billy.config.cache_path) -# FileUtils.rm_rf(local_cache_path) if File.exist?(local_cache_path) -# end -# end -# end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index b6784ede..cdf08084 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,4 +1,4 @@ -# == Define helpers +require "capybara/cuprite" # Generates the arguments to register chrome as capybara driver # @@ -70,15 +70,19 @@ def chrome_driver_arguments(headless: false) Capybara::Selenium::Driver.new(app, **chrome_driver_arguments(headless: true)) end -require 'billy/capybara/rspec' -Capybara.javascript_driver = :headless_chrome - -# Register our custom driver name. otherwise 'screenshot_failed_tests' would fail -# see https://github.com/mattheworiordan/capybara-screenshot/issues/84#issuecomment-41219326 -Capybara::Screenshot.register_driver(Capybara.javascript_driver) do |driver, path| - driver.browser.save_screenshot(path) +Capybara.register_driver(:cuprite) do |app| + logger = StringIO.new + Capybara::Cuprite::Driver.new(app, window_size: [ 1024, 860 ], + js_errors: true, + logger: logger, + browser_options: { 'no-sandbox': nil }) end +# https://github.com/rubycdp/cuprite +Capybara.javascript_driver = :cuprite + + Capybara.default_driver = Capybara.javascript_driver +Capybara::Screenshot.autosave_on_failure = true # Start Puma silently Capybara.server = :puma, { Silent: true } diff --git a/spec/support/feature_helpers.rb b/spec/support/feature_helpers.rb index 15edabb7..846545c0 100644 --- a/spec/support/feature_helpers.rb +++ b/spec/support/feature_helpers.rb @@ -1,9 +1,20 @@ -module Features - module Helpers - def self.take_a_screenshot - filename = Rails.root.join("capybara-#{Time.zone.now.to_i}.png") - puts "\033[36mINFO: Saving screenshot at: #{filename}\033[0m\n\n" - page.save_screenshot(filename, full: true) - end - end +def take_a_screenshot + filename = Rails.root.join("tmp", "capybara", "screen-#{Time.zone.now.to_i}.png") + puts "\033[36mINFO: Saving screenshot at: #{filename}\033[0m\n\n" + browser = page.driver.browser + browser.screenshot(path: filename, full: true) +end + +def element_offset_height(selector) + page.driver.browser.evaluate <<~JS + document.querySelector('#{selector}').offsetHeight; + JS +end + +def set_color_input(selector, color) + color_input = find(selector) + + page.execute_script("arguments[0].value = '#{color}'", color_input) + page.execute_script("arguments[0].dispatchEvent(new Event('input', { bubbles: true }))", color_input) + page.execute_script("arguments[0].dispatchEvent(new Event('change', { bubbles: true }))", color_input) end diff --git a/spec/support/mouse_helpers.rb b/spec/support/mouse_helpers.rb index 40a81729..c98b41aa 100644 --- a/spec/support/mouse_helpers.rb +++ b/spec/support/mouse_helpers.rb @@ -1,13 +1,30 @@ -# Selenium Webdriver -# https://rubydoc.info/github/jnicklas/capybara/Capybara/Selenium/Driver +# 0,0 is the left top of the page -# 0,0 is the middle of the element -def click_coord(selector, x, y) - element = find(selector) - page.driver.browser.action.move_to(element.native, x, y).click.perform +def center_of_screen + viewport = page.driver.browser.evaluate <<~JS + { width: window.innerWidth, height: window.innerHeight } + JS + + { x: viewport['width'] / 2, y: viewport['height'] / 2 } +end + +def click_center_of_screen + center = center_of_screen + page.driver.click(center[:x], center[:y]) +end + +def hover_center_of_screen + browser = page.driver.browser + center = center_of_screen + browser.mouse.move(x: center[:x], y: center[:y]) +end + +def click_coord(_selector, x, y) + browser = page.driver.browser + browser.mouse.click(x: x, y: y) end -def hover_coord(selector, x, y) - element = find(selector) - page.driver.browser.action.move_to(element.native, x, y).perform +def hover_coord(x, y) + browser = page.driver.browser + browser.mouse.move(x: x, y: y) end