diff --git a/.rubocop.yml b/.rubocop.yml index b68b776..8a0dde7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -59,3 +59,10 @@ Metrics/PerceivedComplexity: plugins: - rubocop-rake + +Naming/VariableNumber: + CheckSymbols: false + +Layout/LineLength: + Exclude: + - lib/capybara/cuprite/devices.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 323f65d..ca9cc07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ ## [Unreleased](https://github.com/rubycdp/cuprite/compare/v0.18...main) ## ### Added +- `:emulate` driver option to emulate a device listed in Chrome DevTools with its user agent, e.g. + `emulate: :iphone_14`, along with `:mobile` and `:scale_factor` options it sets, kept across window resizes [#216] ### Changed +- Ferrum is bumped to `~> 0.19.0` ### Fixed - `Driver#reset!` kept the page it had cached when the browser failed to reset, so every later example raised diff --git a/README.md b/README.md index 361258b..126d8d5 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,18 @@ end timer) only surfaces on whatever command runs next. * `:url_blacklist` (Array) - array of regexes to match against requested URLs * `:url_whitelist` (Array) - array of regexes to match against requested URLs + * `:emulate` (Symbol) - device from Chrome DevTools' list to emulate, e.g. `:iphone_16`, `:pixel_9`, `:ipad_mini` + * `:mobile` (Boolean) - emulate a mobile device: viewport meta tag and touch events + * `:scale_factor` (Numeric) - device scale factor to emulate +`emulate:` sets the device's `:window_size`, `:scale_factor`, `:mobile` and user agent, options passed explicitly +take precedence: + +```ruby +Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new(app, emulate: :iphone_14) +end +``` ## Debugging diff --git a/cuprite.gemspec b/cuprite.gemspec index 7dd2e59..81dfea2 100644 --- a/cuprite.gemspec +++ b/cuprite.gemspec @@ -25,5 +25,5 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 3.1" s.add_dependency "capybara", "~> 3.0" - s.add_dependency "ferrum", "~> 0.18.0" + s.add_dependency "ferrum", "~> 0.19.0" end diff --git a/docs/2-customization.md b/docs/2-customization.md index 454133e..da8de80 100644 --- a/docs/2-customization.md +++ b/docs/2-customization.md @@ -27,6 +27,14 @@ Cuprite-specific options are: [URL filtering](/docs/cuprite/url-filtering). * `:url_whitelist` (Array) - Regexes to match against requested URLs that are allowed, see [URL filtering](/docs/cuprite/url-filtering). + * `:emulate` (Symbol) - Device to emulate, one of those listed in Chrome DevTools' device toolbar, such as + `:iphone_16`, `:pixel_9`, `:samsung_galaxy_a55` or `:ipad_mini`, see `Capybara::Cuprite::Devices::LIST`. It sets + the device's `:window_size`, `:scale_factor` and `:mobile`, options passed explicitly take precedence, and its + user agent, unless a `User-Agent` is set with [headers](/docs/cuprite/headers). + * `:mobile` (Boolean) - Emulate a mobile device: honor `` and enable touch events, `false` + by default. Kept across window resizes. + * `:scale_factor` (Numeric) - Device scale factor (`window.devicePixelRatio`) to emulate, `0` (browser default) + by default. Cuprite changes a few Ferrum defaults: @@ -49,6 +57,12 @@ Capybara::Cuprite::Driver.new(app, timeout: 15, process_timeout: 30) # Connect to a remote browser Capybara::Cuprite::Driver.new(app, url: "http://chrome:3333") + +# Emulate an iPhone 14 +Capybara::Cuprite::Driver.new(app, emulate: :iphone_14) + +# Emulate a Pixel 9 with a custom scale factor +Capybara::Cuprite::Driver.new(app, emulate: :pixel_9, scale_factor: 1) ``` ## Environment variables diff --git a/lib/capybara/cuprite.rb b/lib/capybara/cuprite.rb index 9b868d3..c9413bc 100644 --- a/lib/capybara/cuprite.rb +++ b/lib/capybara/cuprite.rb @@ -8,6 +8,7 @@ require "capybara/cuprite/browser" require "capybara/cuprite/page" require "capybara/cuprite/options" +require "capybara/cuprite/devices" require "capybara/cuprite/node" require "capybara/cuprite/errors" diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb index 09efce0..7c385a0 100644 --- a/lib/capybara/cuprite/browser.rb +++ b/lib/capybara/cuprite/browser.rb @@ -14,6 +14,8 @@ class Browser < Ferrum::Browser dismiss_prompt reset_modals] => :page def initialize(options = nil) + device = options&.dig(:emulate) + options = Devices.fetch(device).except(:user_agent).merge(options) if device super @options.raise_on_unhandled_modal = options&.dig(:raise_on_unhandled_modal) diff --git a/lib/capybara/cuprite/devices.rb b/lib/capybara/cuprite/devices.rb new file mode 100644 index 0000000..921fbae --- /dev/null +++ b/lib/capybara/cuprite/devices.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +module Capybara + module Cuprite + module Devices + IPHONE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" + SAMSUNG_GALAXY_S8_PLUS_USER_AGENT = "Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + WINDOWS_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36" + + LIST = { + iphone_se: { + window_size: [375, 667], scale_factor: 2, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_xr: { + window_size: [414, 896], scale_factor: 2, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_12_pro: { + window_size: [390, 844], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_14: { + window_size: [390, 844], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_14_plus: { + window_size: [428, 926], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_14_pro: { + window_size: [393, 852], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_14_pro_max: { + window_size: [430, 932], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_15: { + window_size: [393, 852], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_15_plus: { + window_size: [430, 932], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_15_pro: { + window_size: [393, 852], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_15_pro_max: { + window_size: [430, 932], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_16e: { + window_size: [390, 844], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_16: { + window_size: [393, 852], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_16_plus: { + window_size: [430, 932], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_16_pro: { + window_size: [402, 874], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + iphone_16_pro_max: { + window_size: [440, 956], scale_factor: 3, mobile: true, + user_agent: IPHONE_USER_AGENT + }, + ipad_mini: { + window_size: [768, 1024], scale_factor: 2, mobile: true, + user_agent: "Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" + }, + ipad_pro_13: { + window_size: [1032, 1376], scale_factor: 2, mobile: true, + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15" + }, + + pixel_7: { + window_size: [412, 915], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_8: { + window_size: [412, 915], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_8_pro: { + window_size: [448, 997], scale_factor: 3, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_8a: { + window_size: [412, 915], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_9: { + window_size: [412, 924], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_9_pro: { + window_size: [427, 952], scale_factor: 3, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_9_pro_xl: { + window_size: [448, 997], scale_factor: 3, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_10: { + window_size: [412, 924], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + pixel_9_pro_fold: { + window_size: [412, 922], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro Fold) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + + samsung_galaxy_s8_plus: { + window_size: [360, 740], scale_factor: 4, mobile: true, + user_agent: SAMSUNG_GALAXY_S8_PLUS_USER_AGENT + }, + samsung_galaxy_s20_ultra: { + window_size: [412, 915], scale_factor: 3.5, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + samsung_galaxy_a55: { + window_size: [360, 800], scale_factor: 2.25, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + galaxy_z_fold_6: { + window_size: [412, 968], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + galaxy_z_fold_5: { + window_size: [344, 882], scale_factor: 2.625, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + samsung_galaxy_a51_71: { + window_size: [412, 914], scale_factor: 2.625, mobile: true, + user_agent: SAMSUNG_GALAXY_S8_PLUS_USER_AGENT + }, + galaxy_tab_s4: { + window_size: [712, 1138], scale_factor: 2.25, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36" + }, + + surface_pro_7: { + window_size: [912, 1368], scale_factor: 2, mobile: true, + user_agent: WINDOWS_USER_AGENT + }, + surface_pro_10: { + window_size: [960, 1440], scale_factor: 2, mobile: true, + user_agent: WINDOWS_USER_AGENT + }, + surface_duo: { + window_size: [540, 720], scale_factor: 2.5, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 11.0; Surface Duo) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + + nest_hub_max: { + window_size: [1280, 800], scale_factor: 2, mobile: true, + user_agent: "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36 CrKey/1.54.250320" + }, + jiophone_2: { + window_size: [240, 320], scale_factor: 1, mobile: true, + user_agent: "Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5" + }, + moto_g4: { + window_size: [360, 640], scale_factor: 3, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 6.0.1; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + }, + moto_g_power: { + window_size: [412, 823], scale_factor: 1.75, mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36" + } + }.freeze + + def self.fetch(name) + LIST.fetch(name.to_sym) do + raise ArgumentError, "Unknown device to emulate: #{name.inspect}, use one of #{LIST.keys.join(', ')}" + end + end + end + end +end diff --git a/lib/capybara/cuprite/options.rb b/lib/capybara/cuprite/options.rb index 40a124c..1f76202 100644 --- a/lib/capybara/cuprite/options.rb +++ b/lib/capybara/cuprite/options.rb @@ -6,6 +6,25 @@ class Options attr_writer :window_size attr_accessor :url_blacklist, :url_whitelist, :raise_on_unhandled_modal + def mobile + @options.fetch(:mobile, false) + end + + def scale_factor + @options.fetch(:scale_factor, 0) + end + + def device_user_agent + Capybara::Cuprite::Devices.fetch(@options[:emulate])[:user_agent] if @options[:emulate] + end + + def default_user_agent + return @default_user_agent unless device_user_agent + + chrome_version = @default_user_agent.to_s[%r{Chrome/(\S+)}, 1].to_s + format(device_user_agent, version: chrome_version) + end + def reset_window_size @window_size = @options[:window_size] end diff --git a/lib/capybara/cuprite/page.rb b/lib/capybara/cuprite/page.rb index fa34251..ce7d3e4 100644 --- a/lib/capybara/cuprite/page.rb +++ b/lib/capybara/cuprite/page.rb @@ -44,6 +44,10 @@ def command(...) raise_pending_unhandled_modal_error! { super } end + def set_viewport(width: 0, height: 0, scale_factor: @options.scale_factor, mobile: @options.mobile) + super + end + def set(node, value) object_id = command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId") evaluate("_cuprite.set(arguments[0], arguments[1])", { "objectId" => object_id }, value) @@ -175,6 +179,7 @@ def prepare_page width, height = @options.window_size resize(width: width, height: height) + command("Network.setUserAgentOverride", userAgent: default_user_agent) if @options.device_user_agent if @options.url_blacklist.any? network.blacklist = @options.url_blacklist diff --git a/spec/features/driver_spec.rb b/spec/features/driver_spec.rb index 12965cf..d012097 100644 --- a/spec/features/driver_spec.rb +++ b/spec/features/driver_spec.rb @@ -8,11 +8,12 @@ module Capybara module Cuprite describe Driver do let(:device_pixel_ratio) { @driver.device_pixel_ratio } + let(:mobile) { false } include Spec::Support::ExternalBrowser around do |example| - @session = TestSessions::Cuprite + @session = mobile ? TestSessions::CupriteMobile : TestSessions::Cuprite @driver = @session.driver example.run ensure @@ -50,6 +51,94 @@ def session_url(path) driver&.quit end + describe "mobile emulation" do + context "when mobile emulation is enabled" do + let(:mobile) { true } + + it "emulates a mobile browser" do + @session.visit("/cuprite/mobile") + expect(@session).to have_text("I am a mobile.") + expect(@session).to have_no_text("I am a desktop.") + expect(@session.evaluate_script("'ontouchstart' in window || navigator.maxTouchPoints > 0")).to be_truthy + end + + it "applies the device scale factor" do + @session.visit("/cuprite/mobile") + expect(@session.evaluate_script("window.devicePixelRatio")).to eq(3) + end + + it "keeps emulating a mobile browser after resizing the window" do + @session.visit("/cuprite/mobile") + @driver.resize(414, 896) + + expect(@session).to have_text("I am a mobile.") + expect(@session.evaluate_script("navigator.maxTouchPoints > 0")).to be(true) + expect(@session.evaluate_script("window.devicePixelRatio")).to eq(3) + end + + it "sends the device user agent" do + @session.visit("/cuprite/headers") + + expect(@driver.body).to include("USER_AGENT: Mozilla/5.0 (iPhone; CPU iPhone OS") + expect(@session.evaluate_script("navigator.userAgent")).to start_with("Mozilla/5.0 (iPhone;") + end + + it "keeps the device user agent when other headers are set" do + @driver.headers = { "DV" => "hello" } + @session.visit("/cuprite/headers") + + expect(@driver.body).to include("DV: hello") + expect(@session.evaluate_script("navigator.userAgent")).to start_with("Mozilla/5.0 (iPhone;") + end + end + + context "when emulating a device with explicit options" do + let(:session) { Capybara::Session.new(:cuprite_pixel, TestApp) } + + before do + Capybara.register_driver(:cuprite_pixel) do |app| + Capybara::Cuprite::Driver.new(app, emulate: :pixel_9, scale_factor: 1) + end + end + + after { session.driver.quit } + + it "lets the options override the device" do + session.visit("/cuprite/mobile") + + expect(session).to have_text("I am a mobile.") + expect(session.evaluate_script("window.devicePixelRatio")).to eq(1) + end + + it "fills in the running Chrome version" do + session.visit("/cuprite/mobile") + version = session.driver.browser.process.default_user_agent[%r{Chrome/(\S+)}, 1] + + expect(session.evaluate_script("navigator.userAgent")).to eq( + "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) " \ + "Chrome/#{version} Mobile Safari/537.36" + ) + end + end + + it "raises an error for an unknown device" do + driver = Capybara::Cuprite::Driver.new(nil, emulate: :nokia_3310) + + expect { driver.browser }.to raise_error(ArgumentError, /Unknown device to emulate: :nokia_3310/) + end + + context "when mobile emulation is disabled" do + let(:mobile) { false } + + it "does not emulate a mobile browser" do + @session.visit("/cuprite/mobile") + expect(@session).to have_text("I am a desktop.") + expect(@session).to have_no_text("I am a mobile.") + expect(@session.evaluate_script("'ontouchstart' in window || navigator.maxTouchPoints > 0")).to be_falsy + end + end + end + context "output redirection" do let(:logger) { StringIO.new } let(:session) { Capybara::Session.new(:cuprite_with_logger, TestApp) } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cc3c56f..deb496d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -29,8 +29,17 @@ Capybara::Cuprite::Driver.new(app, options) end +Capybara.register_driver(:cuprite_mobile) do |app| + options = { emulate: :iphone_14 } + options.merge!(inspector: true) if ENV["INSPECTOR"] + options.merge!(logger: StringIO.new) if ENV["CI"] + options.merge!(headless: false) if ENV["HEADLESS"] == "false" + Capybara::Cuprite::Driver.new(app, options) +end + module TestSessions Cuprite = Capybara::Session.new(:cuprite, TestApp) + CupriteMobile = Capybara::Session.new(:cuprite_mobile, TestApp) end RSpec.configure do |config| diff --git a/spec/support/views/mobile.erb b/spec/support/views/mobile.erb new file mode 100644 index 0000000..13a1620 --- /dev/null +++ b/spec/support/views/mobile.erb @@ -0,0 +1,26 @@ + + + + Mobile test page + + + +
+ I am a desktop. +
+ +
+ I am a mobile. +
+ +