From 3cd6db89b780ae9d3aeb2627f2bdb7cbc9b9efd3 Mon Sep 17 00:00:00 2001 From: Duncan Bayne Date: Mon, 4 May 2026 15:45:17 +1000 Subject: [PATCH 1/2] chore(deps): bump ferrum to 0.19.0 --- CHANGELOG.md | 1 + cuprite.gemspec | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 323f65d9..961fcbf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added ### 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/cuprite.gemspec b/cuprite.gemspec index 7dd2e599..81dfea2a 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 From f7a4e2e4349d38f9711c21b938ad57c554a12471 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Thu, 15 Oct 2026 19:51:02 +0300 Subject: [PATCH 2/2] feat(driver): emulate devices from Chrome DevTools with `emulate:` Add `emulate:` driver option that takes a device listed in Chrome DevTools' device toolbar, e.g. `emulate: :iphone_14`, and sets its window size, scale factor, mobile viewport with touch events and user agent. The Chrome version is filled into the device user agent as DevTools does, and it stays when other headers are set. `:mobile` and `:scale_factor` work on their own too and take precedence over the device's. They are applied in `Page#set_viewport`, so they're kept across window resizes. Closes #216, supersedes #292. Co-authored-by: Duncan Bayne --- .rubocop.yml | 7 ++ CHANGELOG.md | 2 + README.md | 11 ++ docs/2-customization.md | 14 +++ lib/capybara/cuprite.rb | 1 + lib/capybara/cuprite/browser.rb | 2 + lib/capybara/cuprite/devices.rb | 188 ++++++++++++++++++++++++++++++++ lib/capybara/cuprite/options.rb | 19 ++++ lib/capybara/cuprite/page.rb | 5 + spec/features/driver_spec.rb | 91 +++++++++++++++- spec/spec_helper.rb | 9 ++ spec/support/views/mobile.erb | 26 +++++ 12 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 lib/capybara/cuprite/devices.rb create mode 100644 spec/support/views/mobile.erb diff --git a/.rubocop.yml b/.rubocop.yml index b68b7760..8a0dde7f 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 961fcbf8..ca9cc072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [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` diff --git a/README.md b/README.md index 361258b8..126d8d5f 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/docs/2-customization.md b/docs/2-customization.md index 454133e0..da8de800 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 9b868d33..c9413bc6 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 09efce01..7c385a0f 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 00000000..921fbaed --- /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 40a124c7..1f762025 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 fa342519..ce7d3e43 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 12965cfc..d0120972 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 cc3c56fa..deb496da 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 00000000..13a16204 --- /dev/null +++ b/spec/support/views/mobile.erb @@ -0,0 +1,26 @@ + + + + Mobile test page + + + +
+ I am a desktop. +
+ +
+ I am a mobile. +
+ +