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.
+
+
+