From 27978b5357d8fd33200eb4049f7a438c08b0fee9 Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Fri, 17 Oct 2025 21:16:30 +0500 Subject: [PATCH 1/8] test: refactor specs for readability and helper extraction * restructured RSpec tests for DeviceDetector with subject, let, and context; * replaced explicit class instantiations with described_class.new; * extracted utility logic into new helpers (ConditionalHelper, MatcherHelper); * cleaned up spec_helper.rb, auto-requiring all files under spec/support; * improved fixture specs and test isolation. --- spec/device_detector/client_fixtures_spec.rb | 20 +-- .../concrete_user_agent_spec.rb | 2 +- .../device_detector/detector_fixtures_spec.rb | 114 +++++++++--------- spec/device_detector/device_fixtures_spec.rb | 23 ++-- spec/device_detector/device_spec.rb | 2 +- spec/device_detector/memory_cache_spec.rb | 2 +- spec/device_detector/os_fixtures_spec.rb | 28 +++-- spec/spec_helper.rb | 107 ++++++++-------- spec/support/conditional_helper.rb | 32 +++++ spec/support/matcher_helper.rb | 8 ++ 10 files changed, 198 insertions(+), 140 deletions(-) create mode 100644 spec/support/conditional_helper.rb create mode 100644 spec/support/matcher_helper.rb diff --git a/spec/device_detector/client_fixtures_spec.rb b/spec/device_detector/client_fixtures_spec.rb index 0c05bec..13df03b 100644 --- a/spec/device_detector/client_fixtures_spec.rb +++ b/spec/device_detector/client_fixtures_spec.rb @@ -3,6 +3,8 @@ require_relative '../spec_helper' describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + fixture_dir = File.expand_path('../fixtures/client', __dir__) fixture_files = Dir["#{fixture_dir}/*.yml"] @@ -12,20 +14,20 @@ describe File.basename(fixture_file) do fixtures = YAML.load_file(fixture_file).first(40) fixtures.each do |f| - user_agent = f['user_agent'] - headers = f['headers'] + describe f['user_agent'] do + let(:fixture) { f } - describe user_agent do - let(:client) do - DeviceDetector.new(user_agent, headers) - end + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:client) { f['client'] } + let(:client_result) { subject.send(:client_result) } it 'should be known' do - expect(client.known?).to eq(true) + expect(subject.known?).to eq true end - it 'should have the expected name' do - expect(client.name).to eq(f['client']['name']) + it 'should have expected name' do + expect(subject.name).to eq client['name'] end end end diff --git a/spec/device_detector/concrete_user_agent_spec.rb b/spec/device_detector/concrete_user_agent_spec.rb index 8cff945..c094dd0 100644 --- a/spec/device_detector/concrete_user_agent_spec.rb +++ b/spec/device_detector/concrete_user_agent_spec.rb @@ -3,7 +3,7 @@ require_relative '../spec_helper' describe DeviceDetector do - subject { DeviceDetector.new(user_agent) } + subject { described_class.new(user_agent) } alias_method :client, :subject diff --git a/spec/device_detector/detector_fixtures_spec.rb b/spec/device_detector/detector_fixtures_spec.rb index 29bbfba..cc1080d 100644 --- a/spec/device_detector/detector_fixtures_spec.rb +++ b/spec/device_detector/detector_fixtures_spec.rb @@ -3,13 +3,13 @@ require_relative '../spec_helper' describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + fixture_dir = File.expand_path('../fixtures/detector', __dir__) fixture_files = Dir["#{fixture_dir}/*.yml"] raise 'invalid fixture load path specified' if fixture_files.empty? - let(:detector) { DeviceDetector.new } - fixture_files.each do |fixture_file| describe File.basename(fixture_file) do fixtures = nil @@ -21,66 +21,66 @@ fixtures.each do |f| describe f['user_agent'] do - it 'should be detected' do - detector.use(f['user_agent'], f['headers']) + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:bot) { f['bot'] } + let(:client) { f['client'] } + let(:os) { f['os'] } + let(:device) { f['device'] } + + context 'with bot fixture', if: f['bot'] do + it 'should detect bot' do + expect(subject.bot?).to eq true + end + + it 'should detect bot name' do + expect(subject.bot_name).to eq bot['name'] + end + end + + context 'with client fixture', if: f['client'] do + let(:client_result) { subject.send(:client_result) } + + it 'should detect client name' do + expect(subject.name).to eq client['name'] + end + + it 'should detect client short name' do + expect(client_result['short_name']).to eq client['short_name'] + end + end + + context 'with OS fixture', if: f['os'].is_a?(Hash) do + let(:os_result) { subject.send(:os_result) } + + it 'should detect expected OS name' do + expect(subject.os_name).to eq os['name'] + end + + it 'should detect expected OS version' do + expect(subject.os_full_version).to eq str_or_nil(os['version']) + end + + it 'should detect expected OS family' do + expect(subject.os_family).to eq f['os_family'] + end + + it 'should detect expected OS platform' do + expect(os_result[:platform]).to eq str_or_nil(os['platform']) + end + end - if f['bot'] - expect(detector.bot?).to eq true - expect(detector.bot_name).to eq str_or_nil(f['bot']['name']) - next + context 'with device fixture', if: f['device'] do + it 'should detect expected device type' do + expect(subject.device_type).to eq str_or_nil(device['type']) end - expect(detector.name).to eq str_or_nil(f['client']['name']) if f['client'] - - os_family = str_or_nil(f['os_family']) - if os_family != 'Unknown' - if os_family.nil? - expect(detector.os_family).to be_nil - else - expect(detector.os_family).to eq(os_family) - end - - name = str_or_nil(f['os']['name']) - if name.nil? - expect(detector.os_name).to be_nil - else - expect(detector.os_name).to eq name - end - - os_version = str_or_nil(f['os']['version']) - if os_version.nil? - expect(detector.os_full_version).to be_nil - else - expect(detector.os_full_version).to eq os_version - end + it 'should detect expected device brand' do + expect(subject.device_brand).to eq str_or_nil(device['brand']) end - if f['device'] - expected_type = str_or_nil(f['device']['type']) - actual_type = detector.device_type - - if expected_type.nil? - expect(actual_type).to be_nil - else - expect(actual_type).to eq expected_type - end - - model = str_or_nil(f['device']['model']) - model = model.to_s unless model.nil? - - if model.nil? - expect(detector.device_name).to be_nil - else - expect(detector.device_name).to eq model - end - - brand = str_or_nil(f['device']['brand']) - brand = brand.to_s unless brand.nil? - if brand.nil? - expect(detector.device_brand).to be_nil - else - expect(detector.device_brand).to eq brand - end + it 'should detect expected device model' do + expect(subject.device_name).to eq str_or_nil(device['model']) end end end diff --git a/spec/device_detector/device_fixtures_spec.rb b/spec/device_detector/device_fixtures_spec.rb index 6e5246d..1144ce0 100644 --- a/spec/device_detector/device_fixtures_spec.rb +++ b/spec/device_detector/device_fixtures_spec.rb @@ -3,6 +3,8 @@ require_relative '../spec_helper' describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + fixture_dir = File.expand_path('../fixtures/device', __dir__) fixture_files = Dir["#{fixture_dir}/*.yml"] @@ -12,28 +14,27 @@ describe File.basename(fixture_file) do fixtures = YAML.safe_load_file(fixture_file) fixtures.each do |f| - user_agent = f['user_agent'] - headers = f['headers'] + describe f['user_agent'] do + let(:fixture) { f } - describe user_agent do - let(:device) do - DeviceDetector.new(user_agent, headers) - end + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:device) { f['device'] } it 'should be known' do - expect(device).to be_known + expect(subject).to be_known end - it 'should have the expected model' do - expect(device.device_name).to eq(str_or_nil(f['device']['model'])) + it 'should have the expected model', if: device_model?(f) do + expect(subject.device_name).to eq device['model'] end it 'should have the expected brand' do - expect(device.device_brand).to eq(f['device']['brand']) + expect(subject.device_brand).to eq device['brand'] end it 'should have the expected type' do - expect(device.device_type).to eq(f['device']['type']) + expect(subject.device_type).to eq device['type'] end end end diff --git a/spec/device_detector/device_spec.rb b/spec/device_detector/device_spec.rb index 34a3427..1a25dc2 100644 --- a/spec/device_detector/device_spec.rb +++ b/spec/device_detector/device_spec.rb @@ -3,7 +3,7 @@ require_relative '../spec_helper' describe DeviceDetector do - subject { DeviceDetector.new(user_agent) } + subject { described_class.new(user_agent) } alias_method :device, :subject diff --git a/spec/device_detector/memory_cache_spec.rb b/spec/device_detector/memory_cache_spec.rb index 7bf7de2..6fc2ba7 100644 --- a/spec/device_detector/memory_cache_spec.rb +++ b/spec/device_detector/memory_cache_spec.rb @@ -3,7 +3,7 @@ require_relative '../spec_helper' describe DeviceDetector::MemoryCache do - let(:subject) { DeviceDetector::MemoryCache.new(config) } + let(:subject) { described_class.new(config) } let(:config) { {} } diff --git a/spec/device_detector/os_fixtures_spec.rb b/spec/device_detector/os_fixtures_spec.rb index b47083f..f4dadfe 100644 --- a/spec/device_detector/os_fixtures_spec.rb +++ b/spec/device_detector/os_fixtures_spec.rb @@ -3,6 +3,8 @@ require_relative '../spec_helper' describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + fixture_dir = File.expand_path('../fixtures/parser', __dir__) fixture_files = Dir["#{fixture_dir}/oss.yml"] @@ -12,16 +14,28 @@ describe File.basename(fixture_file) do fixtures = YAML.load_file(fixture_file) fixtures.each do |f| - user_agent = f['user_agent'] - headers = f['headers'] + describe f['user_agent'] do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:os) { f['os'] } + let(:os_result) { subject.send(:os_result) } + + it 'should have the expected OS name' do + expect(subject.os_name).to eq os['name'] + end + + it 'should have the expected OS version', if: os_version?(f) do + expect(subject.os_full_version).to eq os['version'] + end - describe user_agent do - let(:device) do - DeviceDetector.new(user_agent, headers) + it 'should have the expected OS family' do + expect(subject.os_family).to eq os['family'] end - it 'should have the expected name' do - expect(device.os_name).to eq f['os']['name'] + it 'should have the expected OS platform', if: os_platform?(f) do + expect(os_result[:platform]).to eq os['platform'] end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 39e7962..af52590 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -20,15 +20,13 @@ require 'byebug' -def str_or_nil(string) - return nil if string.nil? - return nil if string == '' +helpers_dir = File.expand_path('./support', __dir__) +Dir["#{helpers_dir}/**/*.rb"].each { |helper_file| require helper_file } - string.to_s -end +include ConditionalHelper RSpec.configure do |config| - config.example_status_persistence_file_path = 'rspec-status.file' + config.example_status_persistence_file_path = 'tmp/rspec-status.file' # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest @@ -62,51 +60,54 @@ def str_or_nil(string) # The settings below are suggested to provide a good initial experience # with RSpec, but feel free to customize to your heart's content. - # # This allows you to limit a spec run to individual examples or groups - # # you care about by tagging them with `:focus` metadata. When nothing - # # is tagged with `:focus`, all examples get run. RSpec also provides - # # aliases for `it`, `describe`, and `context` that include `:focus` - # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - # config.filter_run_when_matching :focus - # - # # Allows RSpec to persist some state between runs in order to support - # # the `--only-failures` and `--next-failure` CLI options. We recommend - # # you configure your source control system to ignore this file. - # config.example_status_persistence_file_path = "spec/examples.txt" - # - # # Limits the available syntax to the non-monkey patched syntax that is - # # recommended. For more details, see: - # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - # config.disable_monkey_patching! - # - # # This setting enables warnings. It's recommended, but in some cases may - # # be too noisy due to issues in dependencies. - # config.warnings = true - # - # # Many RSpec users commonly either run the entire suite or an individual - # # file, and it's useful to allow more verbose output when running an - # # individual spec file. - # if config.files_to_run.one? - # # Use the documentation formatter for detailed output, - # # unless a formatter has already been configured - # # (e.g. via a command-line flag). - # config.default_formatter = "doc" - # end - # - # # Print the 10 slowest examples and example groups at the - # # end of the spec run, to help surface which specs are running - # # particularly slow. - # config.profile_examples = 10 - # - # # Run specs in random order to surface order dependencies. If you find an - # # order dependency and want to debug it, you can fix the order by providing - # # the seed, which is printed after each run. - # # --seed 1234 - # config.order = :random - # - # # Seed global randomization in this process using the `--seed` CLI option. - # # Setting this allows you to use `--seed` to deterministically reproduce - # # test failures related to randomization by passing the same `--seed` value - # # as the one that triggered the failure. - # Kernel.srand config.seed + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + #config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + #config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + #config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + #config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + #config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # Include match helpers + config.include MatcherHelper end diff --git a/spec/support/conditional_helper.rb b/spec/support/conditional_helper.rb new file mode 100644 index 0000000..e6e748f --- /dev/null +++ b/spec/support/conditional_helper.rb @@ -0,0 +1,32 @@ +module ConditionalHelper + def client_engine?(fixture) + extract_and_check(fixture, 'client', 'engine') + end + + def client_family?(fixture) + extract_and_check(fixture, 'client', 'family') + end + + def client_version?(fixture) + extract_and_check(fixture, 'client', 'version') + end + + def device_model?(fixture) + extract_and_check(fixture, 'device', 'model') + end + + def os_platform?(fixture) + extract_and_check(fixture, 'os', 'platform') + end + + def os_version?(fixture) + extract_and_check(fixture, 'os', 'version') + end + + private + + def extract_and_check(fixture, *path) + value = fixture.dig(*path) + !value.to_s.empty? + end +end diff --git a/spec/support/matcher_helper.rb b/spec/support/matcher_helper.rb new file mode 100644 index 0000000..8f9b47f --- /dev/null +++ b/spec/support/matcher_helper.rb @@ -0,0 +1,8 @@ +module MatcherHelper + def str_or_nil(string) + return nil if string.nil? + return nil if string == '' + + string.to_s + end +end From 077759440f3fe3c94e9eac21e5cf8fe76c38e40a Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Sat, 18 Oct 2025 08:18:08 +0500 Subject: [PATCH 2/8] test: improve specs performance with fixture caching * added FixturesLoaderHelper with YAML-to-JSON caching via Oj for faster spec runs; * integrated caching and helper loading into spec_helper.rb with bootsnap setup; * removed redundant require_relative statements from specs; * added development dependencies (bootsnap, oj, rubocop-rspec) and new RSpec task in Rakefile; * cleaned up .gitignore and deleted outdated bot_spec.rb. --- .gitignore | 2 + Gemfile | 3 + Rakefile | 3 + lib/device_detector.rb | 9 +- spec/device_detector/client_fixtures_spec.rb | 37 ++---- .../concrete_user_agent_spec.rb | 2 - .../device_detector/detector_fixtures_spec.rb | 117 ++++++++---------- spec/device_detector/device_fixtures_spec.rb | 47 +++---- spec/device_detector/device_spec.rb | 2 - spec/device_detector/memory_cache_spec.rb | 4 +- spec/device_detector/os_fixtures_spec.rb | 49 +++----- spec/device_detector/parser/bot_spec.rb | 31 ----- spec/device_detector_spec.rb | 2 - spec/spec_helper.rb | 36 ++++-- spec/support/conditional_helper.rb | 2 + spec/support/fixture_loader_helper.rb | 77 ++++++++++++ spec/support/matcher_helper.rb | 2 + 17 files changed, 223 insertions(+), 202 deletions(-) delete mode 100644 spec/device_detector/parser/bot_spec.rb create mode 100644 spec/support/fixture_loader_helper.rb diff --git a/.gitignore b/.gitignore index ae3fdc2..ceade13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store /.bundle/ /.yardoc /Gemfile.lock @@ -7,6 +8,7 @@ /pkg/ /spec/reports/ /tmp/ +!tmp/.gitkeep *.bundle *.so *.o diff --git a/Gemfile b/Gemfile index 845b9bd..3c6634d 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,9 @@ gem 'byebug' gem 'rake' gem 'rspec' gem 'rubocop', '>= 1.75' +gem 'rubocop-rspec' +gem 'bootsnap' +gem 'oj' gem 'browser', require: false gem 'useragent', require: false diff --git a/Rakefile b/Rakefile index f197ca8..706691a 100644 --- a/Rakefile +++ b/Rakefile @@ -2,10 +2,13 @@ require 'rake' require 'rake/testtask' +require 'rspec/core/rake_task' $LOAD_PATH.unshift 'lib' require 'device_detector' +RSpec::Core::RakeTask.new(:spec) + desc 'generate detectable names output for README' task :detectable_names do require 'date' diff --git a/lib/device_detector.rb b/lib/device_detector.rb index 552a9a0..fe6bc9c 100644 --- a/lib/device_detector.rb +++ b/lib/device_detector.rb @@ -4,7 +4,6 @@ require 'device_detector/version' require 'device_detector/memory_cache' - require 'device_detector/client_hint' require 'device_detector/parser/abstract_parser' @@ -39,6 +38,14 @@ class DeviceDetector attr_reader :client_hint, :user_agent + def self.root + @root ||= File.expand_path('..', __dir__) + end + + def self.regexes_dir + File.join(root, 'regexes') + end + def initialize(user_agent = nil, headers = nil) @parsers = {} diff --git a/spec/device_detector/client_fixtures_spec.rb b/spec/device_detector/client_fixtures_spec.rb index 13df03b..7df4d94 100644 --- a/spec/device_detector/client_fixtures_spec.rb +++ b/spec/device_detector/client_fixtures_spec.rb @@ -1,35 +1,24 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector do subject { described_class.new(user_agent, headers) } - fixture_dir = File.expand_path('../fixtures/client', __dir__) - fixture_files = Dir["#{fixture_dir}/*.yml"] - - raise 'invalid fixture load path specified' if fixture_files.empty? + fixtures = load_fixtures('client/*.yml').first(600) + fixtures.each do |f| + describe f['user_agent'] do + let(:fixture) { f } - fixture_files.each do |fixture_file| - describe File.basename(fixture_file) do - fixtures = YAML.load_file(fixture_file).first(40) - fixtures.each do |f| - describe f['user_agent'] do - let(:fixture) { f } + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:client) { f['client'] } + let(:client_result) { subject.send(:client_result) } - let(:user_agent) { f['user_agent'] } - let(:headers) { f['headers'] } - let(:client) { f['client'] } - let(:client_result) { subject.send(:client_result) } - - it 'should be known' do - expect(subject.known?).to eq true - end + it 'should be known' do + expect(subject.known?).to eq true + end - it 'should have expected name' do - expect(subject.name).to eq client['name'] - end - end + it 'should have expected name' do + expect(subject.name).to eq client['name'] end end end diff --git a/spec/device_detector/concrete_user_agent_spec.rb b/spec/device_detector/concrete_user_agent_spec.rb index c094dd0..27f8107 100644 --- a/spec/device_detector/concrete_user_agent_spec.rb +++ b/spec/device_detector/concrete_user_agent_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector do subject { described_class.new(user_agent) } diff --git a/spec/device_detector/detector_fixtures_spec.rb b/spec/device_detector/detector_fixtures_spec.rb index cc1080d..edffb14 100644 --- a/spec/device_detector/detector_fixtures_spec.rb +++ b/spec/device_detector/detector_fixtures_spec.rb @@ -1,88 +1,71 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector do subject { described_class.new(user_agent, headers) } - fixture_dir = File.expand_path('../fixtures/detector', __dir__) - fixture_files = Dir["#{fixture_dir}/*.yml"] - - raise 'invalid fixture load path specified' if fixture_files.empty? + fixtures = load_fixtures('detector/*.yml') + fixtures.each do |f| + describe f['user_agent'] do + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:bot) { f['bot'] } + let(:client) { f['client'] } + let(:os) { f['os'] } + let(:device) { f['device'] } + + context 'with bot fixture', if: f['bot'] do + it 'should detect bot' do + expect(subject.bot?).to eq true + end - fixture_files.each do |fixture_file| - describe File.basename(fixture_file) do - fixtures = nil - begin - fixtures = YAML.load_file(fixture_file) - rescue Psych::SyntaxError => e - raise "Failed to parse #{fixture_file}, reason: #{e}" + it 'should detect bot name' do + expect(subject.bot_name).to eq bot['name'] + end end - fixtures.each do |f| - describe f['user_agent'] do - let(:user_agent) { f['user_agent'] } - let(:headers) { f['headers'] } - let(:bot) { f['bot'] } - let(:client) { f['client'] } - let(:os) { f['os'] } - let(:device) { f['device'] } - - context 'with bot fixture', if: f['bot'] do - it 'should detect bot' do - expect(subject.bot?).to eq true - end + context 'with client fixture', if: f['client'] do + let(:client_result) { subject.send(:client_result) } - it 'should detect bot name' do - expect(subject.bot_name).to eq bot['name'] - end - end - - context 'with client fixture', if: f['client'] do - let(:client_result) { subject.send(:client_result) } - - it 'should detect client name' do - expect(subject.name).to eq client['name'] - end + it 'should detect client name' do + expect(subject.name).to eq client['name'] + end - it 'should detect client short name' do - expect(client_result['short_name']).to eq client['short_name'] - end - end + it 'should detect client short name' do + expect(client_result['short_name']).to eq client['short_name'] + end + end - context 'with OS fixture', if: f['os'].is_a?(Hash) do - let(:os_result) { subject.send(:os_result) } + context 'with OS fixture', if: f['os'].is_a?(Hash) do + let(:os_result) { subject.send(:os_result) } - it 'should detect expected OS name' do - expect(subject.os_name).to eq os['name'] - end + it 'should detect expected OS name' do + expect(subject.os_name).to eq os['name'] + end - it 'should detect expected OS version' do - expect(subject.os_full_version).to eq str_or_nil(os['version']) - end + it 'should detect expected OS version' do + expect(subject.os_full_version).to eq str_or_nil(os['version']) + end - it 'should detect expected OS family' do - expect(subject.os_family).to eq f['os_family'] - end + it 'should detect expected OS family' do + expect(subject.os_family).to eq f['os_family'] + end - it 'should detect expected OS platform' do - expect(os_result[:platform]).to eq str_or_nil(os['platform']) - end - end + it 'should detect expected OS platform' do + expect(os_result[:platform]).to eq str_or_nil(os['platform']) + end + end - context 'with device fixture', if: f['device'] do - it 'should detect expected device type' do - expect(subject.device_type).to eq str_or_nil(device['type']) - end + context 'with device fixture', if: f['device'] do + it 'should detect expected device type' do + expect(subject.device_type).to eq str_or_nil(device['type']) + end - it 'should detect expected device brand' do - expect(subject.device_brand).to eq str_or_nil(device['brand']) - end + it 'should detect expected device brand' do + expect(subject.device_brand).to eq str_or_nil(device['brand']) + end - it 'should detect expected device model' do - expect(subject.device_name).to eq str_or_nil(device['model']) - end - end + it 'should detect expected device model', if: device_model?(f) do + expect(subject.device_name).to eq device['model'] end end end diff --git a/spec/device_detector/device_fixtures_spec.rb b/spec/device_detector/device_fixtures_spec.rb index 1144ce0..5d37ddb 100644 --- a/spec/device_detector/device_fixtures_spec.rb +++ b/spec/device_detector/device_fixtures_spec.rb @@ -1,42 +1,31 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector do subject { described_class.new(user_agent, headers) } - fixture_dir = File.expand_path('../fixtures/device', __dir__) - fixture_files = Dir["#{fixture_dir}/*.yml"] - - raise 'invalid fixture load path specified' if fixture_files.empty? - - fixture_files.each do |fixture_file| - describe File.basename(fixture_file) do - fixtures = YAML.safe_load_file(fixture_file) - fixtures.each do |f| - describe f['user_agent'] do - let(:fixture) { f } + fixtures = load_fixtures('device/*.yml') + fixtures.each do |f| + describe f['user_agent'] do + let(:fixture) { f } - let(:user_agent) { f['user_agent'] } - let(:headers) { f['headers'] } - let(:device) { f['device'] } + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:device) { f['device'] } - it 'should be known' do - expect(subject).to be_known - end + it 'should be known' do + expect(subject).to be_known + end - it 'should have the expected model', if: device_model?(f) do - expect(subject.device_name).to eq device['model'] - end + it 'should have the expected model', if: device_model?(f) do + expect(subject.device_name).to eq device['model'] + end - it 'should have the expected brand' do - expect(subject.device_brand).to eq device['brand'] - end + it 'should have the expected brand' do + expect(subject.device_brand).to eq device['brand'] + end - it 'should have the expected type' do - expect(subject.device_type).to eq device['type'] - end - end + it 'should have the expected type' do + expect(subject.device_type).to eq device['type'] end end end diff --git a/spec/device_detector/device_spec.rb b/spec/device_detector/device_spec.rb index 1a25dc2..abe2ec9 100644 --- a/spec/device_detector/device_spec.rb +++ b/spec/device_detector/device_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector do subject { described_class.new(user_agent) } diff --git a/spec/device_detector/memory_cache_spec.rb b/spec/device_detector/memory_cache_spec.rb index 6fc2ba7..bf1e8c5 100644 --- a/spec/device_detector/memory_cache_spec.rb +++ b/spec/device_detector/memory_cache_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector::MemoryCache do - let(:subject) { described_class.new(config) } + subject { described_class.new(config) } let(:config) { {} } diff --git a/spec/device_detector/os_fixtures_spec.rb b/spec/device_detector/os_fixtures_spec.rb index f4dadfe..3eae4f1 100644 --- a/spec/device_detector/os_fixtures_spec.rb +++ b/spec/device_detector/os_fixtures_spec.rb @@ -1,43 +1,32 @@ # frozen_string_literal: true -require_relative '../spec_helper' - describe DeviceDetector do subject { described_class.new(user_agent, headers) } - fixture_dir = File.expand_path('../fixtures/parser', __dir__) - fixture_files = Dir["#{fixture_dir}/oss.yml"] - - raise 'invalid fixture load path specified' if fixture_files.empty? - - fixture_files.each do |fixture_file| - describe File.basename(fixture_file) do - fixtures = YAML.load_file(fixture_file) - fixtures.each do |f| - describe f['user_agent'] do - let(:fixture) { f } + fixtures = load_fixtures('parser/oss.yml') + fixtures.each do |f| + describe f['user_agent'] do + let(:fixture) { f } - let(:user_agent) { f['user_agent'] } - let(:headers) { f['headers'] } - let(:os) { f['os'] } - let(:os_result) { subject.send(:os_result) } + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + let(:os) { f['os'] } + let(:os_result) { subject.send(:os_result) } - it 'should have the expected OS name' do - expect(subject.os_name).to eq os['name'] - end + it 'should have the expected OS name' do + expect(subject.os_name).to eq os['name'] + end - it 'should have the expected OS version', if: os_version?(f) do - expect(subject.os_full_version).to eq os['version'] - end + it 'should have the expected OS version', if: os_version?(f) do + expect(subject.os_full_version).to eq os['version'] + end - it 'should have the expected OS family' do - expect(subject.os_family).to eq os['family'] - end + it 'should have the expected OS family' do + expect(subject.os_family).to eq os['family'] + end - it 'should have the expected OS platform', if: os_platform?(f) do - expect(os_result[:platform]).to eq os['platform'] - end - end + it 'should have the expected OS platform', if: os_platform?(f) do + expect(os_result[:platform]).to eq os['platform'] end end end diff --git a/spec/device_detector/parser/bot_spec.rb b/spec/device_detector/parser/bot_spec.rb deleted file mode 100644 index fc89be3..0000000 --- a/spec/device_detector/parser/bot_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -describe DeviceDetector::Parser::Bot do - fixture_dir = File.expand_path('../../fixtures/detector', __dir__) - fixture_files = Dir["#{fixture_dir}/bots.yml"] - - raise 'invalid fixture load path specified' if fixture_files.empty? - - fixture_files.each do |fixture_file| - describe File.basename(fixture_file) do - fixtures = YAML.load_file(fixture_file) - - fixtures.each do |f| - user_agent = f['user_agent'] - headers = f['headers'] - - device = DeviceDetector.new(user_agent, headers) - - describe user_agent do - it 'should be a bot' do - expect(device.bot?).to eq(true) - end - - it 'should have the expected name' do - expect(device.bot_name).to eq(f['bot']['name']) - end - end - end - end - end -end diff --git a/spec/device_detector_spec.rb b/spec/device_detector_spec.rb index ee74a0f..b174c82 100644 --- a/spec/device_detector_spec.rb +++ b/spec/device_detector_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'spec_helper' - describe DeviceDetector do subject { DeviceDetector.new(user_agent) } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index af52590..47a6951 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -15,16 +15,26 @@ # it. # # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration -$:.unshift(File.expand_path('../lib', __dir__)) -require 'device_detector' +require 'bootsnap' + +BOOTSNAP_CACHE_DIR = File.expand_path('../tmp', __dir__) + +Bootsnap.setup( + cache_dir: BOOTSNAP_CACHE_DIR, + development_mode: true, + load_path_cache: true, + compile_cache_iseq: true, + compile_cache_yaml: true +) + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require 'device_detector' require 'byebug' helpers_dir = File.expand_path('./support', __dir__) Dir["#{helpers_dir}/**/*.rb"].each { |helper_file| require helper_file } -include ConditionalHelper - RSpec.configure do |config| config.example_status_persistence_file_path = 'tmp/rspec-status.file' @@ -84,12 +94,12 @@ # Many RSpec users commonly either run the entire suite or an individual # file, and it's useful to allow more verbose output when running an # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end # Print the 10 slowest examples and example groups at the # end of the spec run, to help surface which specs are running @@ -108,6 +118,10 @@ # as the one that triggered the failure. Kernel.srand config.seed - # Include match helpers + # Include matcher helpers config.include MatcherHelper + + # Extend class helpers + config.extend ConditionalHelper + config.extend FixturesLoaderHelper end diff --git a/spec/support/conditional_helper.rb b/spec/support/conditional_helper.rb index e6e748f..bd54e63 100644 --- a/spec/support/conditional_helper.rb +++ b/spec/support/conditional_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ConditionalHelper def client_engine?(fixture) extract_and_check(fixture, 'client', 'engine') diff --git a/spec/support/fixture_loader_helper.rb b/spec/support/fixture_loader_helper.rb new file mode 100644 index 0000000..80659aa --- /dev/null +++ b/spec/support/fixture_loader_helper.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'yaml' +require 'oj' +require 'fileutils' + +module FixturesLoaderHelper + CACHE_PATH = File.join(DeviceDetector.root, 'tmp/cache/fixtures.json') + + def fixtures_dir + File.join(DeviceDetector.root, 'spec/fixtures') + end + + def load_fixtures(paths = '**/*.yml') + paths = "#{fixtures_dir}/#{paths}" + file_list = + case paths + when String then Dir.glob(paths) + when Array then paths + else + raise ArgumentError, "Expected String or Array, got #{paths.class}" + end + + raise ArgumentError, "No files found within these paths: #{paths}" if file_list.empty? + + yaml_data.slice(*file_list).values.flatten + end + + private + + def yaml_data + @yaml_data ||= load_with_cache.freeze + end + + def load_with_cache + if cache_valid? + warn "[YAML Loader] Using json cache: #{CACHE_PATH}" + return Oj.load_file(CACHE_PATH) + end + + warn '[YAML Loader] Cache not found or outdated. Rebuilding...' + + data = load_all_yaml(fixtures_dir) + ensure_cache_dir! + Oj.to_file(CACHE_PATH, data, mode: :compat, indent: 2) + data + end + + def cache_valid? + return false unless File.exist?(CACHE_PATH) + + cache_mtime = File.mtime(CACHE_PATH) + yaml_files = Dir.glob(File.join(fixtures_dir, '**/*.yml')) + latest_yaml_mtime = yaml_files.map { |f| File.mtime(f) }.max + + latest_yaml_mtime && cache_mtime > latest_yaml_mtime + end + + def load_all_yaml(base_dir) + paths = Dir.glob(File.join(base_dir, '**/*.yml')) + warn "[YAML Loader] Loading #{paths.size} YAML-files from #{base_dir}..." + + results = paths.map do |path| + [path, YAML.load_file(path)] + rescue StandardError => e + warn "[YAML Loader] Error parsing #{path}: #{e.message}" + [path, {}] + end + + results.to_h + end + + def ensure_cache_dir! + dir = File.dirname(CACHE_PATH) + FileUtils.mkdir_p(dir) + end +end diff --git a/spec/support/matcher_helper.rb b/spec/support/matcher_helper.rb index 8f9b47f..0ec21a5 100644 --- a/spec/support/matcher_helper.rb +++ b/spec/support/matcher_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MatcherHelper def str_or_nil(string) return nil if string.nil? From 40d1874823ec2be696b51397268e47aaaa8349f7 Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Sat, 18 Oct 2025 18:53:10 +0500 Subject: [PATCH 3/8] feat(parser): add browser engine detection and refactor specs * added engine and engine_version parsing based on browser_engine.yml * implemented build_engine_version with regex-based UA extraction * updated specs to validate engine name, version, and family * improved DeviceDetector::Parser for safer version comparison * refactored helper methods and cleaned up duplicated logic --- lib/device_detector.rb | 6 +- lib/device_detector/parser/abstract_parser.rb | 16 +- lib/device_detector/parser/client/browser.rb | 202 ++++++++++++++++-- .../parser/client/browser_module/engine.rb | 55 +++++ .../client/browser_module/engine/version.rb | 49 +++++ .../parser/client/hint/app_hints.rb | 2 - .../parser/client/hint/browser_hints.rb | 2 - .../parser/operating_system.rb | 9 +- spec/device_detector/client_fixtures_spec.rb | 26 ++- .../device_detector/detector_fixtures_spec.rb | 18 +- spec/device_detector/device_fixtures_spec.rb | 2 +- spec/device_detector/os_fixtures_spec.rb | 2 +- spec/fixtures/detector/desktop.yml | 2 +- spec/fixtures/detector/smartphone-4.yml | 2 +- spec/support/conditional_helper.rb | 4 + 15 files changed, 346 insertions(+), 51 deletions(-) create mode 100644 lib/device_detector/parser/client/browser_module/engine.rb create mode 100644 lib/device_detector/parser/client/browser_module/engine/version.rb diff --git a/lib/device_detector.rb b/lib/device_detector.rb index fe6bc9c..c52903a 100644 --- a/lib/device_detector.rb +++ b/lib/device_detector.rb @@ -16,6 +16,8 @@ require 'device_detector/parser/client/media_player' require 'device_detector/parser/client/pim' require 'device_detector/parser/client/browser' +require 'device_detector/parser/client/browser_module/engine' +require 'device_detector/parser/client/browser_module/engine/version' require 'device_detector/parser/client/library' require 'device_detector/parser/device/abstract_device_parser' require 'device_detector/parser/device/hbb_tv' @@ -437,9 +439,7 @@ def should_parse? end def presence(var) - return nil if var.nil? - return nil if var.empty? - return nil if var == '' + return nil if var.to_s.empty? var end diff --git a/lib/device_detector/parser/abstract_parser.rb b/lib/device_detector/parser/abstract_parser.rb index a784b4a..17e63df 100644 --- a/lib/device_detector/parser/abstract_parser.rb +++ b/lib/device_detector/parser/abstract_parser.rb @@ -26,10 +26,7 @@ def use(uas, hints) protected def empty?(var) - return true if var.nil? - return true if var.empty? - - false + var.to_s.empty? end def fuzzy_compare(val1, val2) @@ -40,9 +37,7 @@ def build_version(version_string, matches) return unless version_string version_string = build_by_match(version_string, matches) - version_string = version_string.gsub('_', '.') - - version_string.strip.sub(/^(\.+)/, '').sub(/(\.+)$/, '') + version_string.gsub('_', '.').chomp('.') end def build_by_match(item, matches) @@ -220,6 +215,13 @@ def deep_symbolize_keys(obj) obj end end + + def satisfied_by_version?(requirement_string, version) + requirement = Gem::Requirement.new(requirement_string) + requirement.satisfied_by?(Gem::Version.new(version)) + rescue Gem::Requirement::BadRequirementError + true + end end end end diff --git a/lib/device_detector/parser/client/browser.rb b/lib/device_detector/parser/client/browser.rb index 3082a8a..c94a47f 100644 --- a/lib/device_detector/parser/client/browser.rb +++ b/lib/device_detector/parser/client/browser.rb @@ -8,12 +8,15 @@ module Client class Browser < AbstractClientParser def initialize super - @browser_hints = DeviceDetector::Parser::Client::Hint::BrowserHints.new + @browser_hints = Hint::BrowserHints.new + @engine_parser = BrowserModule::Engine.new + @engine_version_parser = BrowserModule::EngineVersion.new end def use(uas, hints) super @browser_hints.use(uas, hints) + @engine_parser.use(uas, hints) end def self.client_hint_mapping @@ -296,6 +299,7 @@ def self.mobile_only_browser?(browser) 'HE' => 'Helio', 'HN' => 'Herond Browser', 'HX' => 'Hexa Web Browser', + 'H8' => 'HeyTapBrowser', 'HI' => 'Hi Browser', 'HO' => 'hola! Browser', 'H4' => 'Holla Web Browser', @@ -539,6 +543,8 @@ def self.mobile_only_browser?(browser) 'QS' => 'Quick Browser', 'QT' => 'Qutebrowser', 'QU' => 'Quark', + 'Q6' => 'QuarkPC', + 'Q7' => 'Quetta', 'QZ' => 'QupZilla', 'QM' => 'Qwant Mobile', 'Q5' => 'QtWeb', @@ -728,6 +734,65 @@ def self.mobile_only_browser?(browser) h[long.downcase] = short end.freeze + # https://github.com/matomo-org/device-detector/blob/master/Parser/Client/Browser.php#L742 + BROWSER_FAMILIES = { + 'Android Browser' => ['AN'], + 'BlackBerry Browser' => ['BB'], + 'Baidu' => %w[BD BS H6], + 'Amiga' => %w[AV AW], + 'Chrome' => %w[ + CH 2B 7S A0 AC A4 AE AH AI + AO AS BA BM BR C2 C3 C5 C4 + C6 CC CD CE CF CG 1B CI CL + CM CN CP CR CV CW DA DD DG + DR EC EE EU EW FA FS GB GI + H2 HA HE HH HS I3 IR JB KN + KW LF LL LO M1 MA MD MR MS + MT MZ NM NR O0 O2 O3 OC PB + PT QU QW RM S4 S6 S8 S9 SB + SG SS SU SV SW SY SZ T1 TA + TB TG TR TS TU TV UB UR VE + VG VI VM WP WH XV YJ YN FH + B1 BO HB PC LA LT PD HR HU + HP IO TP CJ HQ HI PN BW YO + DC G8 DT AP AK UI SD VN 4S + 2S RF LR SQ BV L1 F0 KS V0 + C8 AZ MM BT N0 P0 F3 VS DU + D0 P1 O4 8S H3 TE WB K1 P2 + XO U0 B0 VA X0 NX O5 R1 I1 + HO A5 X1 18 B5 B6 TC A6 2X + F4 YG WR NA DM 1M A7 XN XT + XB W1 HT B8 F5 B9 WA T0 HC + O6 P7 LJ LC O7 N2 A8 P8 RB + 1W EV I9 V4 H4 1T M5 0S 0C + ZR D6 F6 RC WD P3 FT A9 X2 + N3 GD O9 Q3 F7 K2 P5 H5 V3 + K3 Q4 G2 R2 WX XP 3I BG R0 + JO OL GN W4 QI E1 RI 8B 5B + K4 WK T3 K5 MU 9P K6 VR N9 + M9 F9 0P 0A JR D3 TK BP 2F + 2M K7 1N 8A H7 X3 T4 X4 5O + 8C 3M 6I 2P PU 7I X5 AL 3P + W2 ZB HN Q6 Q7 H8 + ], + 'Firefox' => %w[ + FF BI BF BH BN C0 CU EI F1 + FB FE AX FM FR FY I4 IF 8P + IW LH LY MB MN MO MY OA OS + PI PX QA S5 SX TF TO WF ZV + FP AD 2I P9 KJ WY VK W5 + 7C N7 W7 + ], + 'Internet Explorer' => %w[IE CZ BZ IM PS 3A 4A RN 2E], + 'Konqueror' => ['KO'], + 'NetFront' => ['NF'], + 'NetSurf' => ['NE'], + 'Nokia Browser' => %w[NB DO NO NV], + 'Opera' => %w[OP OG OH OI OM ON OO O1 OX Y1], + 'Safari' => %w[SF S7 MF SO PV], + 'Sailfish Browser' => ['SA'] + }.freeze + MOBILE_ONLY_BROWSERS = %w[ 36 AH AI BL C1 C4 CB CW DB 3M DT EU EZ FK FM FR FX GH @@ -764,7 +829,8 @@ def parse name = browser_from_client_hints[:name] version = browser_from_client_hints[:version] short = browser_from_client_hints[:short_name] - # engine is not ported yet + engine = '' + engine_version = '' if version =~ /^202[0-4]/ name = 'Iridium' @@ -776,17 +842,18 @@ def parse short = '3B' end - if browser_from_ua[:version] && %w[A0 AL HP JR MU OM OP - VR].include?(short) + if browser_from_ua[:version] && %w[A0 AL HP JR MU OM OP VR].include?(short) version = browser_from_ua[:version] end - # if ('Vewd Browser' === $name) -- engine only + if name == 'Vewd Browser' + engine = browser_from_ua[:engine] + engine_version = browser_from_ua[:engine_version] + end if ['Chromium', 'Chrome Webview'].include?(name) \ - && browser_from_ua[:name] \ + && !browser_from_ua[:name].empty? \ && !%w[CR CV AN].include?(browser_from_ua[:short_name]) - name = browser_from_ua[:name] short = browser_from_ua[:short_name] version = browser_from_ua[:version] @@ -797,42 +864,90 @@ def parse short = browser_from_ua[:short_name] end - # engine only 'if' - # engine only 'if' + # If user agent detects another browser, but the family matches, we use the detected engine from user agent + if name != browser_from_ua[:name] && browser_family(name) == browser_family(browser_from_ua[:name]) + engine = browser_from_ua[:engine] + engine_version = browser_from_ua[:engine_version] + end + + if name == browser_from_ua[:name] + engine = browser_from_ua[:engine] + engine_version = browser_from_ua[:engine_version] + end # TODO: more detailed version detection here - # https://github.com/matomo-org/device-detector/blob/6.4.5/Parser/Client/Browser.php#L1040 - # if browser_from_ua['version'] && + # https://github.com/matomo-org/device-detector/blob/master/Parser/Client/Browser.php#L1044 + if browser_from_ua[:version] && !browser_from_ua[:version]&.empty? && browser_from_ua[:version].include?(version.to_s) && satisfied_by_version?(">= #{version}", browser_from_ua[:version]) + + version = browser_from_ua[:version] + end version = '' if name == 'DuckDuckGo Privacy Browser' - # engine only 'if' + if engine == 'Blink' && name != 'Iridium' && satisfied_by_version?( + "> #{engine_version}", browser_from_client_hints[:version] + ) + + engine_version = browser_from_client_hints[:version] + end else + name = browser_from_ua[:name] short = browser_from_ua[:short_name] version = browser_from_ua[:version] + engine = browser_from_ua[:engine] + engine_version = browser_from_ua[:engine_version] end - # family + family = browser_family(short) + # TODO: https://github.com/matomo-org/device-detector/blob/6.4.5/Parser/Client/Browser.php#L1066 app_hash = @browser_hints.parse + if app_hash&.fetch(:name, nil) != nil name = app_hash[:name] - version = '' + version ||= '' short = browser_short_name(name) - # not implemented: - # if match_user_agent('Chrome/.+ Safari/537.36') - # engine + family only + if @user_agent.match?(%r{Chrome/.+ Safari/537.36}i) + engine = 'Blink' + family = browser_family(short) || 'Chrome' + built_engine_version = build_engine_version(engine) + if satisfied_by_version?(">= #{engine_version}", built_engine_version) + engine_version = built_engine_version + end + end + + raise "Detected browser name '#{name}' was not found in AVAILABLE_BROWSERS. Tried to parse user agent: #{@user_agent}" if short.nil? end return nil if (name.nil? || name == '') || @user_agent.match?(/Cypress|PhantomJS/) + engine_version = '' if engine == 'Blink' && name == 'Flow Browser' + + if name == 'Every Browser' + family = 'Chrome' + engine = 'Blink' + engine_version = '' + end + + if name == 'TV-Browser Internet' && engine == 'Gecko' + family = 'Chrome' + engine = 'Blink' + engine_version = '' + end + + family = 'Chrome' if name == 'Wolvic' && engine == 'Blink' + family = 'Firefox' if name == 'Wolvic' && engine == 'Gecko' + { type: 'browser', name: name, short_name: short, - version: version + version: version, + engine: engine, + engine_version: engine_version, + family: family } end @@ -849,7 +964,6 @@ def parse_browser_from_client_hints brands.each do |info_hash| brand = info_hash[:brand] brand_version = info_hash[:version] - brand = apply_client_hint_mapping(brand).to_s AVAILABLE_BROWSERS.each do |browser_short, browser_name| @@ -866,7 +980,7 @@ def parse_browser_from_client_hints break if !empty?(name) && name != 'Chromium' && name != 'Microsoft Edge' end - version = @client_hints.brand_version if @client_hints.brand_version + version = @client_hints.brand_version unless @client_hints.brand_version.empty? end { @@ -889,7 +1003,9 @@ def parse_browser_from_user_agent return { name: '', short_name: '', - version: '' + version: '', + engine: '', + engine_version: '' } end @@ -898,15 +1014,55 @@ def parse_browser_from_user_agent if browser_short version = build_version(regex[:version], matches) + engine = build_engine(regex[:engine] || {}, version) + engine_version = build_engine_version(engine) return { name: name, short_name: browser_short, - version: version + version: version, + engine: engine, + engine_version: engine_version } end - raise "Detected browser name #{name} was not found in $availableBrowsers. Tried to parse user agent: #{@user_agent}" + raise "Detected browser name #{name} was not found in AVAILABLE_BROWSERS. Tried to parse user agent: #{@user_agent}" + end + + # https://github.com/matomo-org/device-detector/blob/master/Parser/Client/Browser.php#L1238 + def build_engine(engine_data, browser_version) + engine = engine_data[:default] + + if engine_data[:versions] + engine_data[:versions].each do |version, version_engine| + engine = version_engine if !empty?(version) && satisfied_by_version?("> #{version}", browser_version) + end + end + + return engine if engine && !engine.empty? + + @engine_parser.parse[:engine] || '' + end + + def build_engine_version(engine) + @engine_version_parser.use(@user_agent, engine) + result = @engine_version_parser.parse + result[:version] || '' + end + + def more_detailed_version(*versions) + versions.compact.inject do|result, version| + version.to_s.split('.').size > result.to_s.split('.').size ? version : result + end + end + + # https://github.com/matomo-org/device-detector/blob/master/Parser/Client/Browser.php#L927-L945 + def browser_family(browser_label) + return unless AVAILABLE_BROWSERS.keys.include?(browser_label) + + BROWSER_FAMILIES.find do |_family, browser_labels| + browser_labels.include?(browser_label) + end&.first end def browser_short_name(name) diff --git a/lib/device_detector/parser/client/browser_module/engine.rb b/lib/device_detector/parser/client/browser_module/engine.rb new file mode 100644 index 0000000..81c90d0 --- /dev/null +++ b/lib/device_detector/parser/client/browser_module/engine.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class DeviceDetector + module Parser + module Client + module BrowserModule + class Engine < AbstractClientParser + # https://github.com/matomo-org/device-detector/blob/master/Parser/Client/Browser/Engine.php#L39-L60 + BROWSER_ENGINES = %w[ + WebKit + Blink + Trident + Text-based + Dillo + iCab + Elektra + Presto + Clecko + Gecko + KHTML + NetFront + Edge + NetSurf + Servo + Goanna + EkiohFlow + Arachne + LibWeb + Maple + ].freeze + + DOWNCASED_BROWSER_ENGINES = BROWSER_ENGINES.map(&:downcase).freeze + + def parse + parsed = super.dup + + return { engine: '' } if parsed.nil? + + { engine: parsed[:name] } if DOWNCASED_BROWSER_ENGINES.include?(parsed[:name].downcase) + end + + protected + + def fixture_file + 'regexes/client/browser_engine.yml' + end + + def parser_name + 'browserengine' + end + end + end + end + end +end diff --git a/lib/device_detector/parser/client/browser_module/engine/version.rb b/lib/device_detector/parser/client/browser_module/engine/version.rb new file mode 100644 index 0000000..1011ce9 --- /dev/null +++ b/lib/device_detector/parser/client/browser_module/engine/version.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'device_detector/parser/client/hint/browser_hints' + +class DeviceDetector + module Parser + module Client + module BrowserModule + class EngineVersion < AbstractClientParser + attr_reader :user_agent, :engine + + def use(uas, engine) + @user_agent = uas + @engine = engine + end + + def parse + return {} if engine&.empty? + + if %w[Gecko Clecko].include?(engine) + pattern = %r{rv[: ]([0-9]+(?:\.[0-9]+)*)(?:[a-z]\d*)?.*(?:g|cl)ecko/[0-9]{8,10}}i + + if (matches = @user_agent.match(pattern)) + return { version: matches[1] } + end + end + + engine_token = engine.dup + + engine_token = 'Chr[o0]me|Chromium|Cronet' if engine == 'Blink' + engine_token = 'Arachne\/5\.' if engine == 'Arachne' + engine_token = 'LibWeb\+LibJs' if engine == 'LibWeb' + engine_token = 'Chr[o0]me|Chromium|Cronet' if engine == 'Blink' + + matches = @user_agent.match(%r{(?:#{engine_token})\s*[/_]?\s*(\d+(?:\.\d+)*|\d{1,7})(?=\D|$)}i) + + { version: matches.to_a.last } + end + + protected + + def parser_name + 'engine version' + end + end + end + end + end +end diff --git a/lib/device_detector/parser/client/hint/app_hints.rb b/lib/device_detector/parser/client/hint/app_hints.rb index ab828a1..5256b96 100644 --- a/lib/device_detector/parser/client/hint/app_hints.rb +++ b/lib/device_detector/parser/client/hint/app_hints.rb @@ -17,11 +17,9 @@ def parse return nil unless @client_hints app_id = @client_hints.app - return nil if app_id.nil? name = regexes[app_id] - return nil if name == '' { name: name } diff --git a/lib/device_detector/parser/client/hint/browser_hints.rb b/lib/device_detector/parser/client/hint/browser_hints.rb index 9a727e1..7043c83 100644 --- a/lib/device_detector/parser/client/hint/browser_hints.rb +++ b/lib/device_detector/parser/client/hint/browser_hints.rb @@ -17,11 +17,9 @@ def parse return nil unless @client_hints app_id = @client_hints.app - return nil if app_id.nil? name = regexes[app_id] - return nil if name == '' { name: name } diff --git a/lib/device_detector/parser/operating_system.rb b/lib/device_detector/parser/operating_system.rb index 66e059b..85e95df 100644 --- a/lib/device_detector/parser/operating_system.rb +++ b/lib/device_detector/parser/operating_system.rb @@ -125,7 +125,7 @@ def parse family: family } - if OPERATING_SYSTEMS.key?(result[:name]) + if OPERATING_SYSTEMS.value?(result[:name]) result[:short_name], result[:name] = short_os_data(result[:name]) end @@ -223,6 +223,7 @@ def parser_name 'KAL' => 'Kali', 'KAN' => 'Kanotix', 'KIN' => 'KIN OS', + 'KOL' => 'KolibriOS', 'KNO' => 'Knoppix', 'KTV' => 'KreaTV', 'KBT' => 'Kubuntu', @@ -260,6 +261,7 @@ def parser_name 'OS2' => 'OS/2', 'T64' => 'OSF1', 'OBS' => 'OpenBSD', + 'OHS' => 'OpenHarmony', 'OVS' => 'OpenVMS', 'OVZ' => 'OpenVZ', 'OWR' => 'OpenWrt', @@ -350,7 +352,7 @@ def parser_name 'Android' => %w[ AND CYN FIR REM RZD MLD MCD YNS GRI HAR ADR CLR BOS REV LEN SIR RRS WER PIC ARM - HEL BYI RIS PUF LEA MET + HEL BYI RIS PUF LEA MET OHS ], 'AmigaOS' => %w[AMG MOR ARO], 'BlackBerry' => %w[BLB QNX], @@ -377,7 +379,7 @@ def parser_name 'Mac' => ['MAC'], 'Mobile Gaming Console' => %w[PSP NDS XBX], 'OpenVMS' => ['OVS'], - 'Real-time OS' => %w[MTK TDX MRE JME REX RXT], + 'Real-time OS' => %w[MTK TDX MRE JME REX RXT KOL], 'Other Mobile' => %w[WOS POS SBA TIZ SMG MAE LUN GEO], 'Symbian' => %w[SYM SYS SY3 S60 S40], 'Unix' => %w[ @@ -410,6 +412,7 @@ def parser_name }.freeze LINEAGE_OS_VERSION_MAPPING = { + '16' => '23', '15' => '22', '14' => '21', '13' => '20.0', diff --git a/spec/device_detector/client_fixtures_spec.rb b/spec/device_detector/client_fixtures_spec.rb index 7df4d94..ff63325 100644 --- a/spec/device_detector/client_fixtures_spec.rb +++ b/spec/device_detector/client_fixtures_spec.rb @@ -3,11 +3,9 @@ describe DeviceDetector do subject { described_class.new(user_agent, headers) } - fixtures = load_fixtures('client/*.yml').first(600) + fixtures = load_fixtures('client/*.yml') fixtures.each do |f| - describe f['user_agent'] do - let(:fixture) { f } - + describe [f['user_agent'], f['headers']].compact.join(' / ') do let(:user_agent) { f['user_agent'] } let(:headers) { f['headers'] } let(:client) { f['client'] } @@ -20,6 +18,26 @@ it 'should have expected name' do expect(subject.name).to eq client['name'] end + + it 'should have expected version', if: client_version?(f) do + expect(subject.full_version).to eq client['version'].to_s + end + + it 'should have expected type' do + expect(client_result[:type]).to eq client['type'] + end + + it 'should have expected engine', if: client_engine?(f) do + expect(client_result[:engine]).to eq client['engine'] + end + + it 'should have expected engine version', if: client_engine_version?(f) do + expect(client_result[:engine_version]).to eq client['engine_version'] + end + + it 'should have expected family', if: client_family?(f) do + expect(client_result[:family]).to eq client['family'] + end end end end diff --git a/spec/device_detector/detector_fixtures_spec.rb b/spec/device_detector/detector_fixtures_spec.rb index edffb14..4e4941e 100644 --- a/spec/device_detector/detector_fixtures_spec.rb +++ b/spec/device_detector/detector_fixtures_spec.rb @@ -5,7 +5,7 @@ fixtures = load_fixtures('detector/*.yml') fixtures.each do |f| - describe f['user_agent'] do + describe [f['user_agent'], f['headers']].compact.join(' / ') do let(:user_agent) { f['user_agent'] } let(:headers) { f['headers'] } let(:bot) { f['bot'] } @@ -30,8 +30,20 @@ expect(subject.name).to eq client['name'] end - it 'should detect client short name' do - expect(client_result['short_name']).to eq client['short_name'] + it 'should have expected version', if: client_version?(f) do + expect(subject.full_version).to eq client['version'].to_s + end + + it 'should have expected type' do + expect(client_result[:type]).to eq client['type'] + end + + it 'should have expected engine', if: client_engine?(f) do + expect(client_result[:engine]).to eq client['engine'] + end + + it 'should have expected engine version', if: client_engine_version?(f) do + expect(client_result[:engine_version]).to eq client['engine_version'] end end diff --git a/spec/device_detector/device_fixtures_spec.rb b/spec/device_detector/device_fixtures_spec.rb index 5d37ddb..d17ba7d 100644 --- a/spec/device_detector/device_fixtures_spec.rb +++ b/spec/device_detector/device_fixtures_spec.rb @@ -5,7 +5,7 @@ fixtures = load_fixtures('device/*.yml') fixtures.each do |f| - describe f['user_agent'] do + describe [f['user_agent'], f['headers']].compact.join(' / ') do let(:fixture) { f } let(:user_agent) { f['user_agent'] } diff --git a/spec/device_detector/os_fixtures_spec.rb b/spec/device_detector/os_fixtures_spec.rb index 3eae4f1..736198a 100644 --- a/spec/device_detector/os_fixtures_spec.rb +++ b/spec/device_detector/os_fixtures_spec.rb @@ -5,7 +5,7 @@ fixtures = load_fixtures('parser/oss.yml') fixtures.each do |f| - describe f['user_agent'] do + describe [f['user_agent'], f['headers']].compact.join(' / ') do let(:fixture) { f } let(:user_agent) { f['user_agent'] } diff --git a/spec/fixtures/detector/desktop.yml b/spec/fixtures/detector/desktop.yml index 40a0f3b..ddef547 100644 --- a/spec/fixtures/detector/desktop.yml +++ b/spec/fixtures/detector/desktop.yml @@ -3506,7 +3506,7 @@ type: browser name: Maxthon version: "3.0" - engine: WebKit + engine: Trident engine_version: "" device: type: desktop diff --git a/spec/fixtures/detector/smartphone-4.yml b/spec/fixtures/detector/smartphone-4.yml index 8b9df10..1d43aa7 100644 --- a/spec/fixtures/detector/smartphone-4.yml +++ b/spec/fixtures/detector/smartphone-4.yml @@ -28,7 +28,7 @@ name: Chrome Webview version: 63.0.3239 engine: Blink - engine_version: 63.0.3239. + engine_version: 63.0.3239 device: type: smartphone brand: Doro diff --git a/spec/support/conditional_helper.rb b/spec/support/conditional_helper.rb index bd54e63..c49ea2e 100644 --- a/spec/support/conditional_helper.rb +++ b/spec/support/conditional_helper.rb @@ -5,6 +5,10 @@ def client_engine?(fixture) extract_and_check(fixture, 'client', 'engine') end + def client_engine_version?(fixture) + extract_and_check(fixture, 'client', 'engine_version') + end + def client_family?(fixture) extract_and_check(fixture, 'client', 'family') end From af2e5c33c694937a0f98715a20b7447489fc6554 Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Sat, 18 Oct 2025 19:20:45 +0500 Subject: [PATCH 4/8] test: update regexes and fixtures * update fixtures * update regexes * switch update fixtures & regexes rake tasks to revision instead of branch --- Rakefile | 6 +- regexes/bots.yml | 8 + regexes/client/browsers.yml | 12 + regexes/client/hints/browsers.yml | 1 + regexes/client/libraries.yml | 5 + regexes/client/mobile_apps.yml | 26 +- regexes/device/mobiles.yml | 881 +++- regexes/device/portable_media_player.yml | 8 + regexes/oss.yml | 45 +- spec/fixtures/client/browser.yml | 31 + spec/fixtures/client/library.yml | 6 + spec/fixtures/client/mobile_app.yml | 36 + spec/fixtures/detector/bots.yml | 9 + spec/fixtures/detector/mobile_apps.yml | 16 - spec/fixtures/detector/phablet-1.yml | 90 + .../detector/portable_media_player.yml | 18 + spec/fixtures/detector/smartphone-11.yml | 6 +- spec/fixtures/detector/smartphone-12.yml | 10 +- spec/fixtures/detector/smartphone-15.yml | 14 +- spec/fixtures/detector/smartphone-17.yml | 12 +- spec/fixtures/detector/smartphone-22.yml | 6 +- spec/fixtures/detector/smartphone-29.yml | 6 +- spec/fixtures/detector/smartphone-30.yml | 5 +- spec/fixtures/detector/smartphone-32.yml | 4 +- spec/fixtures/detector/smartphone-33.yml | 6 +- spec/fixtures/detector/smartphone-34.yml | 18 +- spec/fixtures/detector/smartphone-35.yml | 12 +- spec/fixtures/detector/smartphone-37.yml | 36 +- spec/fixtures/detector/smartphone-39.yml | 18 +- spec/fixtures/detector/smartphone-40.yml | 18 +- spec/fixtures/detector/smartphone-41.yml | 6 +- spec/fixtures/detector/smartphone-42.yml | 4268 +++++++++++++++++ spec/fixtures/detector/tablet-12.yml | 1710 +++++++ spec/fixtures/detector/tv-2.yml | 3 +- spec/fixtures/detector/tv-4.yml | 2 +- spec/fixtures/detector/tv-5.yml | 648 +++ spec/fixtures/detector/wearable.yml | 36 + spec/fixtures/parser/oss.yml | 52 + 38 files changed, 7796 insertions(+), 298 deletions(-) diff --git a/Rakefile b/Rakefile index 706691a..6beccc2 100644 --- a/Rakefile +++ b/Rakefile @@ -45,14 +45,14 @@ task :detectable_names do end MATOMO_REPO_URL = 'https://github.com/matomo-org/device-detector' -MATOMO_REPO_TAG = '6.4.6' +MATOMO_COMMIT_SHA = '90b44522b16637dcc95e87bd70b3f47a42c50fbe' MATOMO_CHECKOUT_LOCATION = '/tmp/matomo_device_detector' def matomo_checkout! if File.exist?(MATOMO_CHECKOUT_LOCATION) - system "cd #{MATOMO_CHECKOUT_LOCATION}; git fetch origin; git reset --hard #{MATOMO_REPO_TAG}" + system "cd #{MATOMO_CHECKOUT_LOCATION}; git fetch origin; git reset --hard #{MATOMO_COMMIT_SHA}" else - system "git clone --depth 100 #{MATOMO_REPO_URL} -b #{MATOMO_REPO_TAG} #{MATOMO_CHECKOUT_LOCATION}" + system "git clone --depth 100 #{MATOMO_REPO_URL} --revision=#{MATOMO_COMMIT_SHA} #{MATOMO_CHECKOUT_LOCATION}" end end diff --git a/regexes/bots.yml b/regexes/bots.yml index 20361cb..c89e86f 100644 --- a/regexes/bots.yml +++ b/regexes/bots.yml @@ -5033,6 +5033,14 @@ name: 'OpenGraph.io' url: 'https://www.opengraph.io' +- regex: 'microsoft-flow/' + name: 'Microsoft Power Automate' + category: 'Service Agent' + url: 'https://www.microsoft.com/en-us/power-platform/products/power-automate' + producer: + name: 'Microsoft Corporation' + url: 'https://www.microsoft.com/' + # Generic bots - regex: 'nuhk|grub-client|Download Demon|SearchExpress|Microsoft URL Control|borg|altavista|dataminr\.com|teoma|oegp|http%20client|htdig|mogimogi|larbin|scrubby|searchsight|semanticdiscovery|snappy|zeal(?!ot)|dataparksearch|findlinks|BrowserMob|URL2PNG|ZooShot|GomezA|Google SketchUp|Read%20Later|7Siters|centuryb\.o\.t9|InterNaetBoten|EasyBib AutoCite|Bidtellect|tomnomnom/meg|cortex|Re-re Studio|adreview|AHC/|NameOfAgent|Request-Promise|ALittle Client|Hello,? world|wp_is_mobile|0xAbyssalDoesntExist|Anarchy99|^revolt|nvd0rz|xfa1|Hakai|gbrmss|fuck-your-hp|IDBTE4M CODE87|Antoine|Insomania|Hells-Net|b3astmode|Linux Gnu \(cow\)|Test Certificate Info|iplabel|Magellan|TheSafex?Internetx?Search|Searcherx?web|kirkland-signature|LinkChain|survey-security-dot-txt|infrawatch|Time/|r00ts3c-owned-you|nvdorz|Root Slut|NiggaBalls|BotPoke|GlobalWebSearch|xx032_bo9vs83_2a|sslshed|geckotrail|Wordup|Keydrop|\(compatible\)|John Recon|SPARK COMMIT|masjesu|Komaru_The_Cat|Jesus Christ of Nazareth is LORD|Kowai|Hakai|LoliSec|LMAO|^xenu|^(?:chrome|firefox|Abcd|Dark|KvshClient|Node.js|Report Runner|url|Zeus|ZmEu)$|OnlyScans|TheInternetSearchx' name: 'Generic Bot' diff --git a/regexes/client/browsers.yml b/regexes/client/browsers.yml index 19fefb7..875dc45 100644 --- a/regexes/client/browsers.yml +++ b/regexes/client/browsers.yml @@ -5,6 +5,18 @@ # @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later ############### +# HeyTapBrowser or HeyTabAccount (https://play.google.com/store/apps/details?id=com.heytap.browser) +- regex: 'HeyTapBrowser/([\d.]+)' + name: 'HeyTapBrowser' + version: '$1' + +# QuarkPC (https://www.quark.cn/) +- regex: 'QuarkPC/(\d+\.[\.\d]+)' + name: 'QuarkPC' + version: '$1' + engine: + default: 'Blink' + # AltiBrowser (http://www.alticast.co.kr/) - regex: 'AltiBrowser/([\d.]+)' name: 'AltiBrowser' diff --git a/regexes/client/hints/browsers.yml b/regexes/client/hints/browsers.yml index 34e7fcf..3779076 100644 --- a/regexes/client/hints/browsers.yml +++ b/regexes/client/hints/browsers.yml @@ -324,3 +324,4 @@ 'com.cloaktp.browser': 'Privacy Pioneer Browser' 'company.thebrowser.arc': 'Arc Search' 'com.android.webview': 'Chrome Webview' +'com.heytap.browser': 'HeyTapBrowser' diff --git a/regexes/client/libraries.yml b/regexes/client/libraries.yml index 6ec19aa..698155b 100644 --- a/regexes/client/libraries.yml +++ b/regexes/client/libraries.yml @@ -694,3 +694,8 @@ name: 'MatomoTracker' version: '$1' url: 'https://github.com/matomo-org/matomo-sdk-ios' + +- regex: 'libHTTP/(\d+[.\d]+)' + name: 'LibHTTP' + version: '$1' + url: 'https://www.libhttp.org/' diff --git a/regexes/client/mobile_apps.yml b/regexes/client/mobile_apps.yml index 674874b..d789bbb 100644 --- a/regexes/client/mobile_apps.yml +++ b/regexes/client/mobile_apps.yml @@ -146,6 +146,11 @@ name: 'Google Assistant' version: '$1' +# WeCom (https://work.weixin.qq.com/ | https://play.google.com/store/apps/details?id=com.tencent.wework | https://apps.apple.com/app/id1087897068) +- regex: 'wxwork/(\d+[.\d]+)' + name: 'WeCom' + version: '$1' + # WeChat - regex: 'MicroMessenger/([\d.]+)' name: 'WeChat' @@ -418,11 +423,6 @@ name: 'U-Cursos' version: '$1' -# HeyTabBrowser or HeyTabAccount -- regex: 'HeyTapBrowser/([\d.]+)' - name: 'HeyTapBrowser' - version: '$1' - # Roblox App - regex: 'RobloxApp/([\d.]+)' name: 'Roblox' @@ -2686,11 +2686,21 @@ version: '$2' # Generic app -- regex: 'appname/([^/; ]*)' - name: '$1' - version: '' +- regex: 'AppVersion/([\d.]+).+appname/((?!\(null\))[^/; ]*)' + name: '$2' + version: '$1' # AFNetworking generic - regex: '(?!AlohaBrowser)([^/;]*)/(\d+\.[\d.]+) \((?:iPhone|iPad); (?:iOS|iPadOS) [0-9.]+; Scale/[0-9.]+\)' name: '$1' version: '$2' + +# Seekr App (Android) +- regex: '^Seekr/([\d\.]+).*Android' + name: 'Seekr' + version: '$1' + +# Seekr App (iOS) +- regex: '^Seekr/([\d\.]+).*CFNetwork' + name: 'Seekr' + version: '$1' diff --git a/regexes/device/mobiles.yml b/regexes/device/mobiles.yml index dd638f5..6295b43 100644 --- a/regexes/device/mobiles.yml +++ b/regexes/device/mobiles.yml @@ -1541,6 +1541,18 @@ Apple: - regex: '(?:MDCR_|ICRU_|Apple-)?iPh(?:one)?17[C,_]3|(?:iPhone[ _]?16| 16)(?:[);/ ]|$)' model: 'iPhone 16' device: 'phablet' + - regex: '(?:MDCR_|ICRU_|Apple-)?iPh(?:one)?18[C,_]2|(?:iPhone[ _]?17[ _]?Pro[ _]?Max| 17PROMAX)(?:[);/ ]|$)' + model: 'iPhone 17 Pro Max' + device: 'phablet' + - regex: '(?:MDCR_|ICRU_|Apple-)?iPh(?:one)?18[C,_]1|(?:iPhone[ _]?17[ _]?Pro| 17PRO)(?:[);/ ]|$)' + model: 'iPhone 17 Pro' + device: 'phablet' + - regex: '(?:MDCR_|ICRU_|Apple-)?iPh(?:one)?18[C,_]3|(?:iPhone[ _]?17| 17)(?:[);/ ]|$)' + model: 'iPhone 17' + device: 'phablet' + - regex: '(?:MDCR_|ICRU_|Apple-)?iPh(?:one)?18[C,_]4|(?:iPhone[ _]?17[ _]?Air| 17AIR)(?:[);/ ]|$)' + model: 'iPhone Air' + device: 'phablet' # specific tablet devices - regex: '(?:MDCR_|ICRU_|Apple-)?iPad1[C,_]1' @@ -2069,7 +2081,7 @@ Brigmton: # Acer (acer.com) Acer: - regex: 'acer|ACTAB|TravelMate|(? Date: Sun, 19 Oct 2025 00:45:41 +0500 Subject: [PATCH 5/8] feat(test): improve RSpec performance and modularize detector specs * enabled parallel test execution with parallel_tests * extracted shared detector examples into a reusable module * split detector fixture specs into multiple smaller files for parallel runs * introduced FixtureNormalizerHelper for consistent fixture key/value normalization --- .rspec | 2 + Gemfile | 1 + Rakefile | 1 + lib/device_detector/parser/client/browser.rb | 2 +- spec/device_detector/client_fixtures_spec.rb | 23 ++--- .../detector/other_fixtures_spec.rb | 39 +++++++++ .../detector/smartphone_10_fixtures_spec.rb | 33 +++++++ .../detector/smartphone_1_fixtures_spec.rb | 33 +++++++ .../detector/smartphone_20_fixtures_spec.rb | 33 +++++++ .../detector/smartphone_30_fixtures_spec.rb | 33 +++++++ .../detector/smartphone_40_fixtures_spec.rb | 33 +++++++ .../detector/tablet_fixtures_spec.rb | 33 +++++++ .../detector/tv_fixtures_spec.rb | 33 +++++++ .../device_detector/detector_fixtures_spec.rb | 85 ------------------- spec/device_detector/device_fixtures_spec.rb | 4 +- spec/device_detector/os_fixtures_spec.rb | 11 +-- spec/spec_helper.rb | 1 + spec/support/fixture_loader_helper.rb | 5 +- spec/support/fixture_normalizer_helper.rb | 10 +++ .../shared_examples/detector_examples.rb | 55 ++++++++++++ 20 files changed, 358 insertions(+), 112 deletions(-) create mode 100644 spec/device_detector/detector/other_fixtures_spec.rb create mode 100644 spec/device_detector/detector/smartphone_10_fixtures_spec.rb create mode 100644 spec/device_detector/detector/smartphone_1_fixtures_spec.rb create mode 100644 spec/device_detector/detector/smartphone_20_fixtures_spec.rb create mode 100644 spec/device_detector/detector/smartphone_30_fixtures_spec.rb create mode 100644 spec/device_detector/detector/smartphone_40_fixtures_spec.rb create mode 100644 spec/device_detector/detector/tablet_fixtures_spec.rb create mode 100644 spec/device_detector/detector/tv_fixtures_spec.rb delete mode 100644 spec/device_detector/detector_fixtures_spec.rb create mode 100644 spec/support/fixture_normalizer_helper.rb create mode 100644 spec/support/shared_examples/detector_examples.rb diff --git a/.rspec b/.rspec index c99d2e7..d713413 100644 --- a/.rspec +++ b/.rspec @@ -1 +1,3 @@ --require spec_helper +--format progress +--format ParallelTests::RSpec::FailuresLogger --out tmp/failing_specs.log diff --git a/Gemfile b/Gemfile index 3c6634d..319af42 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem 'byebug' gem 'rake' gem 'rspec' +gem 'parallel_tests' gem 'rubocop', '>= 1.75' gem 'rubocop-rspec' gem 'bootsnap' diff --git a/Rakefile b/Rakefile index 6beccc2..24b9887 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require 'rake' require 'rake/testtask' require 'rspec/core/rake_task' +require 'parallel_tests' $LOAD_PATH.unshift 'lib' require 'device_detector' diff --git a/lib/device_detector/parser/client/browser.rb b/lib/device_detector/parser/client/browser.rb index c94a47f..868035b 100644 --- a/lib/device_detector/parser/client/browser.rb +++ b/lib/device_detector/parser/client/browser.rb @@ -943,7 +943,7 @@ def parse { type: 'browser', name: name, - short_name: short, + # short_name: short, version: version, engine: engine, engine_version: engine_version, diff --git a/spec/device_detector/client_fixtures_spec.rb b/spec/device_detector/client_fixtures_spec.rb index ff63325..fc6b4f6 100644 --- a/spec/device_detector/client_fixtures_spec.rb +++ b/spec/device_detector/client_fixtures_spec.rb @@ -8,35 +8,24 @@ describe [f['user_agent'], f['headers']].compact.join(' / ') do let(:user_agent) { f['user_agent'] } let(:headers) { f['headers'] } - let(:client) { f['client'] } + let(:client_result) { subject.send(:client_result) } + let(:client) { normalize_fixture(f['client']) } it 'should be known' do expect(subject.known?).to eq true end it 'should have expected name' do - expect(subject.name).to eq client['name'] + expect(subject.name).to eq client[:name] end it 'should have expected version', if: client_version?(f) do - expect(subject.full_version).to eq client['version'].to_s - end - - it 'should have expected type' do - expect(client_result[:type]).to eq client['type'] - end - - it 'should have expected engine', if: client_engine?(f) do - expect(client_result[:engine]).to eq client['engine'] - end - - it 'should have expected engine version', if: client_engine_version?(f) do - expect(client_result[:engine_version]).to eq client['engine_version'] + expect(subject.full_version.to_s).to eq client[:version] end - it 'should have expected family', if: client_family?(f) do - expect(client_result[:family]).to eq client['family'] + it 'should have client as in fixture' do + expect(client_result).to include(client) end end end diff --git a/spec/device_detector/detector/other_fixtures_spec.rb b/spec/device_detector/detector/other_fixtures_spec.rb new file mode 100644 index 0000000..f9784ce --- /dev/null +++ b/spec/device_detector/detector/other_fixtures_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + paths = Dir.glob("#{fixtures_dir}/detector/*.yml").reject do |path| + path.start_with?("#{fixtures_dir}/detector/smartphone-") || + path.start_with?("#{fixtures_dir}/detector/tv") || + path.start_with?("#{fixtures_dir}/detector/tablet") + end + + fixtures = load_fixtures(paths) + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/smartphone_10_fixtures_spec.rb b/spec/device_detector/detector/smartphone_10_fixtures_spec.rb new file mode 100644 index 0000000..5f94d8a --- /dev/null +++ b/spec/device_detector/detector/smartphone_10_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/smartphone-1?.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/smartphone_1_fixtures_spec.rb b/spec/device_detector/detector/smartphone_1_fixtures_spec.rb new file mode 100644 index 0000000..d12fb00 --- /dev/null +++ b/spec/device_detector/detector/smartphone_1_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/smartphone-?.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/smartphone_20_fixtures_spec.rb b/spec/device_detector/detector/smartphone_20_fixtures_spec.rb new file mode 100644 index 0000000..5fe84cc --- /dev/null +++ b/spec/device_detector/detector/smartphone_20_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/smartphone-2?.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/smartphone_30_fixtures_spec.rb b/spec/device_detector/detector/smartphone_30_fixtures_spec.rb new file mode 100644 index 0000000..551f1e5 --- /dev/null +++ b/spec/device_detector/detector/smartphone_30_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/smartphone-3?.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/smartphone_40_fixtures_spec.rb b/spec/device_detector/detector/smartphone_40_fixtures_spec.rb new file mode 100644 index 0000000..349df71 --- /dev/null +++ b/spec/device_detector/detector/smartphone_40_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/smartphone-4?.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/tablet_fixtures_spec.rb b/spec/device_detector/detector/tablet_fixtures_spec.rb new file mode 100644 index 0000000..d0abc59 --- /dev/null +++ b/spec/device_detector/detector/tablet_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/tablet*.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector/tv_fixtures_spec.rb b/spec/device_detector/detector/tv_fixtures_spec.rb new file mode 100644 index 0000000..c96e5b7 --- /dev/null +++ b/spec/device_detector/detector/tv_fixtures_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../support/shared_examples/detector_examples' + +describe DeviceDetector do + subject { described_class.new(user_agent, headers) } + + fixtures = load_fixtures('detector/tv*.yml') + fixtures.each do |f| + describe [f['user_agent'], f['headers']].compact.join(' / ') do + let(:fixture) { f } + + let(:user_agent) { f['user_agent'] } + let(:headers) { f['headers'] } + + context 'when it is a bot', if: f['bot'] do + it_behaves_like 'detector bot examples' + end + + context 'when it has a client', if: f['client'] do + it_behaves_like 'detector client examples' + end + + context 'when it has an OS', if: f['os'].is_a?(Hash) do + it_behaves_like 'detector OS examples' + end + + context 'when it has a device', if: f['device'] do + it_behaves_like 'detector device examples' + end + end + end +end diff --git a/spec/device_detector/detector_fixtures_spec.rb b/spec/device_detector/detector_fixtures_spec.rb deleted file mode 100644 index 4e4941e..0000000 --- a/spec/device_detector/detector_fixtures_spec.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -describe DeviceDetector do - subject { described_class.new(user_agent, headers) } - - fixtures = load_fixtures('detector/*.yml') - fixtures.each do |f| - describe [f['user_agent'], f['headers']].compact.join(' / ') do - let(:user_agent) { f['user_agent'] } - let(:headers) { f['headers'] } - let(:bot) { f['bot'] } - let(:client) { f['client'] } - let(:os) { f['os'] } - let(:device) { f['device'] } - - context 'with bot fixture', if: f['bot'] do - it 'should detect bot' do - expect(subject.bot?).to eq true - end - - it 'should detect bot name' do - expect(subject.bot_name).to eq bot['name'] - end - end - - context 'with client fixture', if: f['client'] do - let(:client_result) { subject.send(:client_result) } - - it 'should detect client name' do - expect(subject.name).to eq client['name'] - end - - it 'should have expected version', if: client_version?(f) do - expect(subject.full_version).to eq client['version'].to_s - end - - it 'should have expected type' do - expect(client_result[:type]).to eq client['type'] - end - - it 'should have expected engine', if: client_engine?(f) do - expect(client_result[:engine]).to eq client['engine'] - end - - it 'should have expected engine version', if: client_engine_version?(f) do - expect(client_result[:engine_version]).to eq client['engine_version'] - end - end - - context 'with OS fixture', if: f['os'].is_a?(Hash) do - let(:os_result) { subject.send(:os_result) } - - it 'should detect expected OS name' do - expect(subject.os_name).to eq os['name'] - end - - it 'should detect expected OS version' do - expect(subject.os_full_version).to eq str_or_nil(os['version']) - end - - it 'should detect expected OS family' do - expect(subject.os_family).to eq f['os_family'] - end - - it 'should detect expected OS platform' do - expect(os_result[:platform]).to eq str_or_nil(os['platform']) - end - end - - context 'with device fixture', if: f['device'] do - it 'should detect expected device type' do - expect(subject.device_type).to eq str_or_nil(device['type']) - end - - it 'should detect expected device brand' do - expect(subject.device_brand).to eq str_or_nil(device['brand']) - end - - it 'should detect expected device model', if: device_model?(f) do - expect(subject.device_name).to eq device['model'] - end - end - end - end -end diff --git a/spec/device_detector/device_fixtures_spec.rb b/spec/device_detector/device_fixtures_spec.rb index d17ba7d..ef0259b 100644 --- a/spec/device_detector/device_fixtures_spec.rb +++ b/spec/device_detector/device_fixtures_spec.rb @@ -16,8 +16,8 @@ expect(subject).to be_known end - it 'should have the expected model', if: device_model?(f) do - expect(subject.device_name).to eq device['model'] + it 'should have the expected model' do + expect(subject.device_name.to_s).to eq device['model'] end it 'should have the expected brand' do diff --git a/spec/device_detector/os_fixtures_spec.rb b/spec/device_detector/os_fixtures_spec.rb index 736198a..b2da237 100644 --- a/spec/device_detector/os_fixtures_spec.rb +++ b/spec/device_detector/os_fixtures_spec.rb @@ -12,21 +12,22 @@ let(:headers) { f['headers'] } let(:os) { f['os'] } let(:os_result) { subject.send(:os_result) } + let(:os) { normalize_fixture(f['os']) } it 'should have the expected OS name' do - expect(subject.os_name).to eq os['name'] + expect(subject.os_name).to eq os[:name] end it 'should have the expected OS version', if: os_version?(f) do - expect(subject.os_full_version).to eq os['version'] + expect(subject.os_full_version.to_s).to eq os[:version] end it 'should have the expected OS family' do - expect(subject.os_family).to eq os['family'] + expect(subject.os_family).to eq os[:family] end - it 'should have the expected OS platform', if: os_platform?(f) do - expect(os_result[:platform]).to eq os['platform'] + it 'should have OS data as in fixture' do + expect(os_result).to include(os) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 47a6951..3f9f8f7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -120,6 +120,7 @@ # Include matcher helpers config.include MatcherHelper + config.include FixtureNormalizerHelper # Extend class helpers config.extend ConditionalHelper diff --git a/spec/support/fixture_loader_helper.rb b/spec/support/fixture_loader_helper.rb index 80659aa..a3e11ec 100644 --- a/spec/support/fixture_loader_helper.rb +++ b/spec/support/fixture_loader_helper.rb @@ -12,10 +12,11 @@ def fixtures_dir end def load_fixtures(paths = '**/*.yml') - paths = "#{fixtures_dir}/#{paths}" file_list = case paths - when String then Dir.glob(paths) + when String + paths = "#{fixtures_dir}/#{paths}" + Dir.glob(paths) when Array then paths else raise ArgumentError, "Expected String or Array, got #{paths.class}" diff --git a/spec/support/fixture_normalizer_helper.rb b/spec/support/fixture_normalizer_helper.rb new file mode 100644 index 0000000..052ab18 --- /dev/null +++ b/spec/support/fixture_normalizer_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module FixtureNormalizerHelper + # Transform keys to symbols, values to strings and remove nil or empty values + def normalize_fixture(hash) + hash.map do |key, value| + value.to_s.empty? ? nil : [key.to_sym, value.to_s] + end.compact.to_h + end +end diff --git a/spec/support/shared_examples/detector_examples.rb b/spec/support/shared_examples/detector_examples.rb new file mode 100644 index 0000000..4d12634 --- /dev/null +++ b/spec/support/shared_examples/detector_examples.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +shared_examples 'detector bot examples' do + let(:bot) { fixture['bot'] } + + it 'should detect bot' do + expect(subject.bot?).to eq true + end + + it 'should detect bot name' do + expect(subject.bot_name).to eq bot['name'] + end +end + +shared_examples 'detector client examples' do + let(:client_result) { subject.send(:client_result) } + let(:client) { normalize_fixture(fixture['client']) } + + it 'should detect client name' do + expect(subject.name).to eq client[:name] + end + + it 'should have client as in fixture' do + expect(client_result).to include(client) + end +end + +shared_examples 'detector OS examples' do + let(:os_result) { subject.send(:os_result) } + let(:os) { normalize_fixture(fixture['os']) } + + it 'should detect expected OS name' do + expect(subject.os_name).to eq os[:name] + end + + it 'should have OS as in fixture' do + expect(os_result).to include(os) + end +end + +shared_examples 'detector device examples' do + let(:device) { normalize_fixture(fixture['device']) } + + it 'should detect expected device type' do + expect(subject.device_type).to eq device[:type] + end + + it 'should detect expected device brand' do + expect(subject.device_brand).to eq device[:brand] + end + + it 'should detect expected device model' do + expect(subject.device_name).to eq device[:model] + end +end From 43a71e8af14b127f0f369d78029d14b4213aeb48 Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Sun, 19 Oct 2025 18:57:45 +0500 Subject: [PATCH 6/8] fix(parser): fix regexes path, centralize parser registration * fix regexes path to absolute ones * moved parser class registration to a class-level constant improved and expanded RSpec coverage for class-level methods --- .rubocop.yml | 3 + lib/device_detector.rb | 60 +++++++++++-------- lib/device_detector/parser/abstract_parser.rb | 10 +++- lib/device_detector/parser/bot.rb | 2 +- lib/device_detector/parser/client/browser.rb | 15 +++-- .../parser/client/browser_module/engine.rb | 2 +- .../client/browser_module/engine/version.rb | 2 +- .../parser/client/feed_reader.rb | 2 +- .../parser/client/hint/app_hints.rb | 2 +- .../parser/client/hint/browser_hints.rb | 2 +- lib/device_detector/parser/client/library.rb | 2 +- .../parser/client/media_player.rb | 2 +- .../parser/client/mobile_app.rb | 2 +- lib/device_detector/parser/client/pim.rb | 2 +- lib/device_detector/parser/device/camera.rb | 2 +- .../parser/device/car_browser.rb | 2 +- lib/device_detector/parser/device/console.rb | 2 +- lib/device_detector/parser/device/hbb_tv.rb | 2 +- lib/device_detector/parser/device/mobile.rb | 2 +- lib/device_detector/parser/device/notebook.rb | 2 +- .../parser/device/portable_media_player.rb | 2 +- lib/device_detector/parser/device/shell_tv.rb | 2 +- .../parser/operating_system.rb | 2 +- lib/device_detector/parser/vendor_fragment.rb | 2 +- spec/device_detector_spec.rb | 60 +++++++++++++++---- spec/spec_helper.rb | 10 ++-- 26 files changed, 128 insertions(+), 70 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 1b0b1b9..679b789 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,6 @@ +plugins: + - rubocop-rspec + AllCops: TargetRubyVersion: 3.4 Exclude: diff --git a/lib/device_detector.rb b/lib/device_detector.rb index c52903a..d1e5b90 100644 --- a/lib/device_detector.rb +++ b/lib/device_detector.rb @@ -38,39 +38,49 @@ class DeviceDetector REGEX_CACHE = ::DeviceDetector::MemoryCache.new({}) private_constant :REGEX_CACHE - attr_reader :client_hint, :user_agent + class << self + @@parser_classes = [ + Parser::Client::FeedReader, + Parser::Client::MobileApp, + Parser::Client::MediaPlayer, + Parser::Client::Pim, + Parser::Client::Browser, + Parser::Client::Library, + Parser::Device::HbbTv, + Parser::Device::ShellTv, + Parser::Device::Notebook, + Parser::Device::Console, + Parser::Device::CarBrowser, + Parser::Device::Camera, + Parser::Device::PortableMediaPlayer, + Parser::Device::Mobile, + Parser::Bot + ] + + def root + @root ||= File.expand_path('..', __dir__) + end - def self.root - @root ||= File.expand_path('..', __dir__) - end + def regexes_dir + @regexes_dir ||= File.join(root, 'regexes') + end - def self.regexes_dir - File.join(root, 'regexes') + def parser_classes + @@parser_classes + end end + attr_reader :client_hint, :user_agent + def initialize(user_agent = nil, headers = nil) @parsers = {} @vendor_fragment_parser = DeviceDetector::Parser::VendorFragment.new @operating_system_parser = DeviceDetector::Parser::OperatingSystem.new - add_parser(Parser::Client::FeedReader.new) - add_parser(Parser::Client::MobileApp.new) - add_parser(Parser::Client::MediaPlayer.new) - add_parser(Parser::Client::Pim.new) - add_parser(Parser::Client::Browser.new) - add_parser(Parser::Client::Library.new) - - add_parser(Parser::Device::HbbTv.new) - add_parser(Parser::Device::ShellTv.new) - add_parser(Parser::Device::Notebook.new) - add_parser(Parser::Device::Console.new) - add_parser(Parser::Device::CarBrowser.new) - add_parser(Parser::Device::Camera.new) - add_parser(Parser::Device::PortableMediaPlayer.new) - add_parser(Parser::Device::Mobile.new) - - add_parser(Parser::Bot.new) + self.class.parser_classes.each do |klass| + add_parser(klass.new) + end use(user_agent, headers) if user_agent || headers end @@ -135,9 +145,7 @@ class Configuration attr_accessor :max_cache_keys def to_hash - { - max_cache_keys: max_cache_keys - } + { max_cache_keys: max_cache_keys } end end diff --git a/lib/device_detector/parser/abstract_parser.rb b/lib/device_detector/parser/abstract_parser.rb index 17e63df..6825cde 100644 --- a/lib/device_detector/parser/abstract_parser.rb +++ b/lib/device_detector/parser/abstract_parser.rb @@ -102,6 +102,10 @@ def fixture_file '' end + def fixture_path + File.join(DeviceDetector.regexes_dir, fixture_file) + end + def parser_name '' end @@ -122,7 +126,7 @@ def regexes def load_regexes REGEX_CACHE.get_or_set(fixture_file) do - YAML.safe_load_file(fixture_file, + YAML.safe_load_file(fixture_path, permitted_classes: [String, Integer, NilClass, Array, Hash]) end end @@ -197,9 +201,9 @@ def prepare_definition_for_cache(definition) definition end - def regex_from_user_agent_cache(key = nil, &block) + def regex_from_user_agent_cache(key = nil, &) key = "#{parser_name}_#{@user_agent}#{key}" - DeviceDetector.cache.get_or_set(key, &block) + DeviceDetector.cache.get_or_set(key, &) end def deep_symbolize_keys(obj) diff --git a/lib/device_detector/parser/bot.rb b/lib/device_detector/parser/bot.rb index d67b898..2d69f33 100644 --- a/lib/device_detector/parser/bot.rb +++ b/lib/device_detector/parser/bot.rb @@ -18,7 +18,7 @@ def parse protected def fixture_file - 'regexes/bots.yml' + 'bots.yml' end def parser_name diff --git a/lib/device_detector/parser/client/browser.rb b/lib/device_detector/parser/client/browser.rb index 868035b..0245681 100644 --- a/lib/device_detector/parser/client/browser.rb +++ b/lib/device_detector/parser/client/browser.rb @@ -877,7 +877,9 @@ def parse # TODO: more detailed version detection here # https://github.com/matomo-org/device-detector/blob/master/Parser/Client/Browser.php#L1044 - if browser_from_ua[:version] && !browser_from_ua[:version]&.empty? && browser_from_ua[:version].include?(version.to_s) && satisfied_by_version?(">= #{version}", browser_from_ua[:version]) + if browser_from_ua[:version] && !browser_from_ua[:version]&.empty? && browser_from_ua[:version].include?(version.to_s) && satisfied_by_version?( + ">= #{version}", browser_from_ua[:version] + ) version = browser_from_ua[:version] end @@ -918,7 +920,9 @@ def parse end end - raise "Detected browser name '#{name}' was not found in AVAILABLE_BROWSERS. Tried to parse user agent: #{@user_agent}" if short.nil? + if short.nil? + raise "Detected browser name '#{name}' was not found in AVAILABLE_BROWSERS. Tried to parse user agent: #{@user_agent}" + end end return nil if (name.nil? || name == '') || @user_agent.match?(/Cypress|PhantomJS/) @@ -1035,7 +1039,8 @@ def build_engine(engine_data, browser_version) if engine_data[:versions] engine_data[:versions].each do |version, version_engine| - engine = version_engine if !empty?(version) && satisfied_by_version?("> #{version}", browser_version) + engine = version_engine if !empty?(version) && satisfied_by_version?("> #{version}", + browser_version) end end @@ -1051,7 +1056,7 @@ def build_engine_version(engine) end def more_detailed_version(*versions) - versions.compact.inject do|result, version| + versions.compact.inject do |result, version| version.to_s.split('.').size > result.to_s.split('.').size ? version : result end end @@ -1070,7 +1075,7 @@ def browser_short_name(name) end def fixture_file - 'regexes/client/browsers.yml' + 'client/browsers.yml' end def parser_name diff --git a/lib/device_detector/parser/client/browser_module/engine.rb b/lib/device_detector/parser/client/browser_module/engine.rb index 81c90d0..75521bf 100644 --- a/lib/device_detector/parser/client/browser_module/engine.rb +++ b/lib/device_detector/parser/client/browser_module/engine.rb @@ -42,7 +42,7 @@ def parse protected def fixture_file - 'regexes/client/browser_engine.yml' + 'client/browser_engine.yml' end def parser_name diff --git a/lib/device_detector/parser/client/browser_module/engine/version.rb b/lib/device_detector/parser/client/browser_module/engine/version.rb index 1011ce9..4a1d40c 100644 --- a/lib/device_detector/parser/client/browser_module/engine/version.rb +++ b/lib/device_detector/parser/client/browser_module/engine/version.rb @@ -15,7 +15,7 @@ def use(uas, engine) end def parse - return {} if engine&.empty? + return {} if engine && engine.empty? if %w[Gecko Clecko].include?(engine) pattern = %r{rv[: ]([0-9]+(?:\.[0-9]+)*)(?:[a-z]\d*)?.*(?:g|cl)ecko/[0-9]{8,10}}i diff --git a/lib/device_detector/parser/client/feed_reader.rb b/lib/device_detector/parser/client/feed_reader.rb index 429c9f9..3890135 100644 --- a/lib/device_detector/parser/client/feed_reader.rb +++ b/lib/device_detector/parser/client/feed_reader.rb @@ -7,7 +7,7 @@ class FeedReader < AbstractClientParser protected def fixture_file - 'regexes/client/feed_readers.yml' + 'client/feed_readers.yml' end def parser_name diff --git a/lib/device_detector/parser/client/hint/app_hints.rb b/lib/device_detector/parser/client/hint/app_hints.rb index 5256b96..4344201 100644 --- a/lib/device_detector/parser/client/hint/app_hints.rb +++ b/lib/device_detector/parser/client/hint/app_hints.rb @@ -6,7 +6,7 @@ module Client module Hint class AppHints < AbstractParser def fixture_file - 'regexes/client/hints/apps.yml' + 'client/hints/apps.yml' end def parser_name diff --git a/lib/device_detector/parser/client/hint/browser_hints.rb b/lib/device_detector/parser/client/hint/browser_hints.rb index 7043c83..2fe2700 100644 --- a/lib/device_detector/parser/client/hint/browser_hints.rb +++ b/lib/device_detector/parser/client/hint/browser_hints.rb @@ -6,7 +6,7 @@ module Client module Hint class BrowserHints < AbstractParser def fixture_file - 'regexes/client/hints/browsers.yml' + 'client/hints/browsers.yml' end def parser_name diff --git a/lib/device_detector/parser/client/library.rb b/lib/device_detector/parser/client/library.rb index c888e2d..90330be 100644 --- a/lib/device_detector/parser/client/library.rb +++ b/lib/device_detector/parser/client/library.rb @@ -7,7 +7,7 @@ class Library < AbstractClientParser protected def fixture_file - 'regexes/client/libraries.yml' + 'client/libraries.yml' end def parser_name diff --git a/lib/device_detector/parser/client/media_player.rb b/lib/device_detector/parser/client/media_player.rb index b211dd8..3ae58fc 100644 --- a/lib/device_detector/parser/client/media_player.rb +++ b/lib/device_detector/parser/client/media_player.rb @@ -7,7 +7,7 @@ class MediaPlayer < AbstractClientParser protected def fixture_file - 'regexes/client/mediaplayers.yml' + 'client/mediaplayers.yml' end def parser_name diff --git a/lib/device_detector/parser/client/mobile_app.rb b/lib/device_detector/parser/client/mobile_app.rb index fe57b88..1b3b258 100644 --- a/lib/device_detector/parser/client/mobile_app.rb +++ b/lib/device_detector/parser/client/mobile_app.rb @@ -39,7 +39,7 @@ def parse protected def fixture_file - 'regexes/client/mobile_apps.yml' + 'client/mobile_apps.yml' end def parser_name diff --git a/lib/device_detector/parser/client/pim.rb b/lib/device_detector/parser/client/pim.rb index 7806daf..a872cbe 100644 --- a/lib/device_detector/parser/client/pim.rb +++ b/lib/device_detector/parser/client/pim.rb @@ -7,7 +7,7 @@ class Pim < AbstractClientParser protected def fixture_file - 'regexes/client/pim.yml' + 'client/pim.yml' end def parser_name diff --git a/lib/device_detector/parser/device/camera.rb b/lib/device_detector/parser/device/camera.rb index ae67382..71ae2c9 100644 --- a/lib/device_detector/parser/device/camera.rb +++ b/lib/device_detector/parser/device/camera.rb @@ -13,7 +13,7 @@ def parse protected def fixture_file - 'regexes/device/cameras.yml' + 'device/cameras.yml' end def parser_name diff --git a/lib/device_detector/parser/device/car_browser.rb b/lib/device_detector/parser/device/car_browser.rb index e9816dd..0ed072c 100644 --- a/lib/device_detector/parser/device/car_browser.rb +++ b/lib/device_detector/parser/device/car_browser.rb @@ -13,7 +13,7 @@ def parse protected def fixture_file - 'regexes/device/car_browsers.yml' + 'device/car_browsers.yml' end def parser_name diff --git a/lib/device_detector/parser/device/console.rb b/lib/device_detector/parser/device/console.rb index 10fcc23..86cb208 100644 --- a/lib/device_detector/parser/device/console.rb +++ b/lib/device_detector/parser/device/console.rb @@ -13,7 +13,7 @@ def parse protected def fixture_file - 'regexes/device/consoles.yml' + 'device/consoles.yml' end def parser_name diff --git a/lib/device_detector/parser/device/hbb_tv.rb b/lib/device_detector/parser/device/hbb_tv.rb index ce0a901..f2395eb 100644 --- a/lib/device_detector/parser/device/hbb_tv.rb +++ b/lib/device_detector/parser/device/hbb_tv.rb @@ -22,7 +22,7 @@ def parse protected def fixture_file - 'regexes/device/televisions.yml' + 'device/televisions.yml' end def parser_name diff --git a/lib/device_detector/parser/device/mobile.rb b/lib/device_detector/parser/device/mobile.rb index b5310a2..732655b 100644 --- a/lib/device_detector/parser/device/mobile.rb +++ b/lib/device_detector/parser/device/mobile.rb @@ -7,7 +7,7 @@ class Mobile < AbstractDeviceParser protected def fixture_file - 'regexes/device/mobiles.yml' + 'device/mobiles.yml' end def parser_name diff --git a/lib/device_detector/parser/device/notebook.rb b/lib/device_detector/parser/device/notebook.rb index 4f51108..d68c864 100644 --- a/lib/device_detector/parser/device/notebook.rb +++ b/lib/device_detector/parser/device/notebook.rb @@ -18,7 +18,7 @@ def parse protected def fixture_file - 'regexes/device/notebooks.yml' + 'device/notebooks.yml' end def parser_name diff --git a/lib/device_detector/parser/device/portable_media_player.rb b/lib/device_detector/parser/device/portable_media_player.rb index f8f4ba2..422fbde 100644 --- a/lib/device_detector/parser/device/portable_media_player.rb +++ b/lib/device_detector/parser/device/portable_media_player.rb @@ -13,7 +13,7 @@ def parse protected def fixture_file - 'regexes/device/portable_media_player.yml' + 'device/portable_media_player.yml' end def parser_name diff --git a/lib/device_detector/parser/device/shell_tv.rb b/lib/device_detector/parser/device/shell_tv.rb index fe9e192..7cac335 100644 --- a/lib/device_detector/parser/device/shell_tv.rb +++ b/lib/device_detector/parser/device/shell_tv.rb @@ -22,7 +22,7 @@ def parse protected def fixture_file - 'regexes/device/shell_tv.yml' + 'device/shell_tv.yml' end def parser_name diff --git a/lib/device_detector/parser/operating_system.rb b/lib/device_detector/parser/operating_system.rb index 85e95df..60b7e40 100644 --- a/lib/device_detector/parser/operating_system.rb +++ b/lib/device_detector/parser/operating_system.rb @@ -135,7 +135,7 @@ def parse protected def fixture_file - 'regexes/oss.yml' + 'oss.yml' end def parser_name diff --git a/lib/device_detector/parser/vendor_fragment.rb b/lib/device_detector/parser/vendor_fragment.rb index e911e07..a10f1b6 100644 --- a/lib/device_detector/parser/vendor_fragment.rb +++ b/lib/device_detector/parser/vendor_fragment.rb @@ -29,7 +29,7 @@ def prepare_definition_for_cache(definition) end def fixture_file - 'regexes/vendorfragments.yml' + 'vendorfragments.yml' end def parser_name diff --git a/spec/device_detector_spec.rb b/spec/device_detector_spec.rb index b174c82..eafe3e3 100644 --- a/spec/device_detector_spec.rb +++ b/spec/device_detector_spec.rb @@ -1,10 +1,48 @@ # frozen_string_literal: true describe DeviceDetector do - subject { DeviceDetector.new(user_agent) } + subject { described_class.new(user_agent) } alias_method :client, :subject + describe '.root' do + it 'returns gem root directory' do + expect(described_class.root).to eq File.expand_path('..', __dir__) + end + end + + describe '.regexes_dir' do + it 'returns gem regexes directory' do + expect(described_class.regexes_dir).to eq File.expand_path('../regexes', __dir__) + end + end + + describe '.parser_classes' do + let(:parser_classes) do + [ + described_class::Parser::Client::FeedReader, + described_class::Parser::Client::MobileApp, + described_class::Parser::Client::MediaPlayer, + described_class::Parser::Client::Pim, + described_class::Parser::Client::Browser, + described_class::Parser::Client::Library, + described_class::Parser::Device::HbbTv, + described_class::Parser::Device::ShellTv, + described_class::Parser::Device::Notebook, + described_class::Parser::Device::Console, + described_class::Parser::Device::CarBrowser, + described_class::Parser::Device::Camera, + described_class::Parser::Device::PortableMediaPlayer, + described_class::Parser::Device::Mobile, + described_class::Parser::Bot + ] + end + + it 'returns parser classes' do + expect(described_class.parser_classes).to eq parser_classes + end + end + describe 'known user agent' do describe 'desktop chrome browser' do let(:user_agent) do @@ -43,13 +81,13 @@ describe '#known?' do it 'returns true' do - expect(client.known?).to eq true + expect(client.known?).to be true end end describe '#bot?' do it 'returns false' do - expect(client.bot?).to eq false + expect(client.bot?).to be false end end @@ -124,13 +162,13 @@ describe '#known?' do it 'returns false' do - expect(client.known?).to eq false + expect(client.known?).to be false end end describe '#bot?' do it 'returns false' do - expect(client.bot?).to eq false + expect(client.bot?).to be false end end @@ -170,13 +208,13 @@ describe '#known?' do it 'returns false' do - expect(client.known?).to eq false + expect(client.known?).to be false end end describe '#bot?' do it 'returns false' do - expect(client.bot?).to eq false + expect(client.bot?).to be false end end @@ -216,13 +254,13 @@ describe '#known?' do it 'returns false' do - expect(client.known?).to eq false + expect(client.known?).to be false end end describe '#bot?' do it 'returns false' do - expect(client.bot?).to eq false + expect(client.bot?).to be false end end @@ -262,13 +300,13 @@ describe '#known?' do it 'returns false' do - expect(client.known?).to eq false + expect(client.known?).to be false end end describe '#bot?' do it 'returns true' do - expect(client.bot?).to eq true + expect(client.bot?).to be true end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3f9f8f7..2d807ee 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -75,21 +75,21 @@ # is tagged with `:focus`, all examples get run. RSpec also provides # aliases for `it`, `describe`, and `context` that include `:focus` # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - #config.filter_run_when_matching :focus + # config.filter_run_when_matching :focus # Allows RSpec to persist some state between runs in order to support # the `--only-failures` and `--next-failure` CLI options. We recommend # you configure your source control system to ignore this file. - #config.example_status_persistence_file_path = "spec/examples.txt" + # config.example_status_persistence_file_path = "spec/examples.txt" # Limits the available syntax to the non-monkey patched syntax that is # recommended. For more details, see: # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - #config.disable_monkey_patching! + # config.disable_monkey_patching! # This setting enables warnings. It's recommended, but in some cases may # be too noisy due to issues in dependencies. - #config.warnings = true + # config.warnings = true # Many RSpec users commonly either run the entire suite or an individual # file, and it's useful to allow more verbose output when running an @@ -104,7 +104,7 @@ # Print the 10 slowest examples and example groups at the # end of the spec run, to help surface which specs are running # particularly slow. - #config.profile_examples = 10 + # config.profile_examples = 10 # Run specs in random order to surface order dependencies. If you find an # order dependency and want to debug it, you can fix the order by providing From 4840b82fc4ca3dea09a0dfd5352253a0c9509dd8 Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Mon, 20 Oct 2025 14:59:40 +0500 Subject: [PATCH 7/8] feat(core): add cache reset and dynamic fixture reloading - introduced `DeviceDetector.reset_cache!` for global cache reinitialization - added MemoryCache#purge! to fully clear in-memory cache contents - implemented `.add_fixture_path and `.reset_custom_fixtures!` for dynamic fixture loading in parsers - refactored regex loading to merge multiple fixture sources safely - expanded RSpec coverage for cache reset and fixture reloading behavior --- lib/device_detector.rb | 4 + lib/device_detector/memory_cache.rb | 4 + lib/device_detector/parser/abstract_parser.rb | 53 ++++++++++-- spec/device_detector/memory_cache_spec.rb | 10 ++- spec/device_detector/parser/bot_spec.rb | 82 +++++++++++++++++++ spec/device_detector_spec.rb | 10 +++ spec/fixtures/custom_regexes/custom_bot.yml | 6 ++ 7 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 spec/device_detector/parser/bot_spec.rb create mode 100644 spec/fixtures/custom_regexes/custom_bot.yml diff --git a/lib/device_detector.rb b/lib/device_detector.rb index d1e5b90..03f5a19 100644 --- a/lib/device_detector.rb +++ b/lib/device_detector.rb @@ -157,6 +157,10 @@ def cache @cache ||= MemoryCache.new(config.to_hash) end + def reset_cache! + @cache = MemoryCache.new(config.to_hash) + end + def configure @config = Configuration.new yield(config) diff --git a/lib/device_detector/memory_cache.rb b/lib/device_detector/memory_cache.rb index fce9544..86e1fd8 100644 --- a/lib/device_detector/memory_cache.rb +++ b/lib/device_detector/memory_cache.rb @@ -39,6 +39,10 @@ def get_or_set(key, value = nil) set(string_key, value) end + def purge! + @data = {} + end + private def get_hit(key) diff --git a/lib/device_detector/parser/abstract_parser.rb b/lib/device_detector/parser/abstract_parser.rb index 6825cde..2b6535c 100644 --- a/lib/device_detector/parser/abstract_parser.rb +++ b/lib/device_detector/parser/abstract_parser.rb @@ -3,11 +3,29 @@ class DeviceDetector module Parser class AbstractParser - # overriden - def self.client_hint_mapping - {} + class << self + # overriden + def client_hint_mapping + {} + end + + def add_fixture_path(path) + @custom_fixture_paths = (custom_fixture_paths << path).uniq + REGEX_CACHE.purge! + end + + def custom_fixture_paths + @custom_fixture_paths ||= [] + end + + def reset_custom_fixtures! + @custom_fixture_paths = [] + REGEX_CACHE.purge! + end end + attr_writer :user_agent, :client_hints + REGEX_CACHE = ::DeviceDetector::MemoryCache.new({}) private_constant :REGEX_CACHE @@ -21,8 +39,6 @@ def use(uas, hints) @client_hints = hints end - attr_writer :user_agent, :client_hints - protected def empty?(var) @@ -102,8 +118,8 @@ def fixture_file '' end - def fixture_path - File.join(DeviceDetector.regexes_dir, fixture_file) + def fixture_paths + ([File.join(DeviceDetector.regexes_dir, fixture_file)] + self.class.custom_fixture_paths).uniq.compact end def parser_name @@ -124,10 +140,29 @@ def regexes end end + def load_regex_file(path) + YAML.safe_load_file(path, permitted_classes: [String, Integer, NilClass, Array, Hash]) + rescue Errno::ENOENT + warn "[#{self.class}] Fixture file not found: #{path}" + nil + end + def load_regexes REGEX_CACHE.get_or_set(fixture_file) do - YAML.safe_load_file(fixture_path, - permitted_classes: [String, Integer, NilClass, Array, Hash]) + result = nil + fixture_paths.each do |fixture_path| + next unless File.file?(fixture_path) + + result = case result + when Array + result + load_regex_file(fixture_path) + when Hash + result.merge(load_regex_file(fixture_path)) + else + load_regex_file(fixture_path) || result + end + end + result end end diff --git a/spec/device_detector/memory_cache_spec.rb b/spec/device_detector/memory_cache_spec.rb index bf1e8c5..f67c6b9 100644 --- a/spec/device_detector/memory_cache_spec.rb +++ b/spec/device_detector/memory_cache_spec.rb @@ -131,7 +131,7 @@ end end - describe 'cache purging' do + describe 'cache partial purging' do let(:config) { { max_cache_keys: 3 } } it 'purges the cache when key size arrives at max' do @@ -143,4 +143,12 @@ expect(subject.data.keys.size).to eq 3 end end + + describe '.purge!' do + before { subject.set('some_key', 'value') } + + it 'removes all cached keys' do + expect { subject.purge! }.to change(subject, :data).from({ 'some_key' => 'value' }).to({}) + end + end end diff --git a/spec/device_detector/parser/bot_spec.rb b/spec/device_detector/parser/bot_spec.rb new file mode 100644 index 0000000..568e206 --- /dev/null +++ b/spec/device_detector/parser/bot_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +describe DeviceDetector::Parser::Bot do + subject(:parser) { described_class.new } + + let(:user_agent) { 'AdsBot-Google (+http://www.google.com/adsbot.html)' } + + before do + parser.use(user_agent, nil) + end + + after do + described_class.reset_custom_fixtures! + DeviceDetector.reset_cache! + end + + describe '.add_fixture_path' do + it 'adds new fixture path to class instancevar' do + expect { described_class.add_fixture_path('some/path/to/fixture.yaml') } + .to change(described_class, :custom_fixture_paths) + .from([]).to(['some/path/to/fixture.yaml']) + end + end + + describe '.reset_custom_fixtures!' do + before do + described_class.add_fixture_path('some/path/to/fixture.yaml') + end + + it 'resets fixture paths to class to default' do + expect { described_class.reset_custom_fixtures! } + .to change(described_class, :custom_fixture_paths) + .from(['some/path/to/fixture.yaml']).to([]) + end + end + + describe '#parser_type' do + it 'returns bot symbol' do + expect(parser.parser_type).to eq :bot + end + end + + describe '#parse' do + let(:fixture) do + { name: 'Googlebot', + category: 'Search bot', + url: 'https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers', + producer: { name: 'Google Inc.', url: 'https://www.google.com/' } } + end + + context 'when known user agent set' do + it 'returns expected parsed result' do + expect(parser.parse).to include(fixture) + end + end + + context 'when unknown user agent set' do + let(:user_agent) { 'ChromeBrother/1.2.3' } + + it 'returns expected parsed result' do + expect(parser.parse).to be_nil + end + end + + context 'when custom regex has been added to parser' do + let(:user_agent) { 'ChromeBrother/1.2.3' } + let(:fixture) do + { category: 'Spec bot', name: 'My custom User-Agent Bot', + producer: { name: 'Nick Kugaevsky', url: 'https://github.com/kugaevsky' } } + end + + before do + path = File.expand_path('../../fixtures/custom_regexes/custom_bot.yml', __dir__) + described_class.add_fixture_path(path) + end + + it 'returns expected parsed result' do + expect(parser.parse).to include(fixture) + end + end + end +end diff --git a/spec/device_detector_spec.rb b/spec/device_detector_spec.rb index eafe3e3..4654c74 100644 --- a/spec/device_detector_spec.rb +++ b/spec/device_detector_spec.rb @@ -43,6 +43,16 @@ end end + describe '.reset_cache!' do + before { subject.name } + + let(:user_agent) { 'AnyAgent/1.2.3 '} + + it 'resets cache' do + expect { described_class.reset_cache! }.to change { described_class.cache.data.size }.to(0) + end + end + describe 'known user agent' do describe 'desktop chrome browser' do let(:user_agent) do diff --git a/spec/fixtures/custom_regexes/custom_bot.yml b/spec/fixtures/custom_regexes/custom_bot.yml new file mode 100644 index 0000000..feb791b --- /dev/null +++ b/spec/fixtures/custom_regexes/custom_bot.yml @@ -0,0 +1,6 @@ +- regex: 'ChromeBrother' + name: 'My custom User-Agent Bot' + category: 'Spec bot' + producer: + name: 'Nick Kugaevsky' + url: 'https://github.com/kugaevsky' From ab051c167b61f4899cc398c069adefaf355b9df6 Mon Sep 17 00:00:00 2001 From: Nick Kugaevsky Date: Mon, 20 Oct 2025 16:22:20 +0500 Subject: [PATCH 8/8] chore(sync): update fixtures - update fixtures to last revision of Matomo repo - fix path to custom regex file in tests --- Rakefile | 2 +- lib/device_detector/parser/client/browser.rb | 2 +- .../parser/device/abstract_device_parser.rb | 2 +- regexes/bots.yml | 16 ++++++++++++---- regexes/client/hints/browsers.yml | 2 +- regexes/client/libraries.yml | 10 ++++++++++ regexes/client/mobile_apps.yml | 15 +++++++++++++++ regexes/client/pim.yml | 2 +- regexes/device/mobiles.yml | 4 ++-- spec/device_detector/parser/bot_spec.rb | 2 +- spec/fixtures/client/browser.yml | 2 +- spec/fixtures/client/library.yml | 12 ++++++++++++ spec/fixtures/client/mobile_app.yml | 6 +++--- spec/fixtures/client/pim.yml | 2 +- spec/fixtures/detector/bots.yml | 19 ++++++++++++++----- spec/fixtures/detector/peripheral.yml | 2 +- spec/fixtures/detector/smartphone-39.yml | 2 +- .../custom_regexes/custom_bot.yml | 0 spec/support/fixture_loader_helper.rb | 2 +- 19 files changed, 79 insertions(+), 25 deletions(-) rename spec/{fixtures => support}/custom_regexes/custom_bot.yml (100%) diff --git a/Rakefile b/Rakefile index 24b9887..9a0b74e 100644 --- a/Rakefile +++ b/Rakefile @@ -46,7 +46,7 @@ task :detectable_names do end MATOMO_REPO_URL = 'https://github.com/matomo-org/device-detector' -MATOMO_COMMIT_SHA = '90b44522b16637dcc95e87bd70b3f47a42c50fbe' +MATOMO_COMMIT_SHA = '1b521fb382873602ea5d3fed582a7c9d72afbd6f' MATOMO_CHECKOUT_LOCATION = '/tmp/matomo_device_detector' def matomo_checkout! diff --git a/lib/device_detector/parser/client/browser.rb b/lib/device_detector/parser/client/browser.rb index 0245681..2f9e8a4 100644 --- a/lib/device_detector/parser/client/browser.rb +++ b/lib/device_detector/parser/client/browser.rb @@ -519,7 +519,7 @@ def self.mobile_only_browser?(browser) 'BP' => 'Privacy Browser', 'PI' => 'PrivacyWall', 'P4' => 'Privacy Explorer Fast Safe', - 'X5' => 'Privacy Pioneer Browser', + 'X5' => 'Cloak Private Browser', 'P3' => 'Private Internet Browser', 'P5' => 'Proxy Browser', '7P' => 'Proxyium', diff --git a/lib/device_detector/parser/device/abstract_device_parser.rb b/lib/device_detector/parser/device/abstract_device_parser.rb index 321376c..6793d52 100644 --- a/lib/device_detector/parser/device/abstract_device_parser.rb +++ b/lib/device_detector/parser/device/abstract_device_parser.rb @@ -1506,7 +1506,7 @@ def build_model(model, matches) 'PIX' => 'PIXPRO', 'QP' => 'Pico', 'PIR' => 'PIRANHA', - 'PIN' => 'PINE', + 'PIN' => 'PINE64', '9P' => 'Planet Computers', 'PLA' => 'Play Now', 'PY' => 'Ployer', diff --git a/regexes/bots.yml b/regexes/bots.yml index c89e86f..2056df7 100644 --- a/regexes/bots.yml +++ b/regexes/bots.yml @@ -1107,7 +1107,7 @@ url: 'https://github.com/jaimeiniesta/metainspector' - regex: 'MixrankBot' - name: 'Mixrank Bot' + name: 'MixRank Bot' category: 'Crawler' url: 'http://mixrank.com' producer: @@ -2497,7 +2497,7 @@ - regex: 'MoodleBot-Linkchecker' name: 'MoodleBot Linkchecker' category: 'Search bot' - url: 'hhttps://docs.moodle.org/en/Usage' + url: 'https://docs.moodle.org/en/Usage' producer: name: 'Moodle Pty Ltd' url: 'https://moodle.org/' @@ -2652,7 +2652,7 @@ url: 'http://cloudsystemnetworks.com' - regex: 'HeartRails_Capture' - name: 'Heart Rails Capture' + name: 'HeartRails Capture' category: 'Service Agent' url: 'http://capture.heartrails.com' @@ -2849,7 +2849,7 @@ - regex: 'LumtelBot' name: 'LumtelBot' category: 'Crawler' - url: 'https://umtel.com' + url: 'https://lumtel.com' - regex: 'PiplBot' name: 'PiplBot' @@ -5041,6 +5041,14 @@ name: 'Microsoft Corporation' url: 'https://www.microsoft.com/' +- regex: 'Simbiat Software' + name: 'Simbiat Software' + category: 'Crawler' + url: 'https://www.simbiat.eu' + producer: + name: 'Simbiat Software' + url: 'https://www.simbiat.eu' + # Generic bots - regex: 'nuhk|grub-client|Download Demon|SearchExpress|Microsoft URL Control|borg|altavista|dataminr\.com|teoma|oegp|http%20client|htdig|mogimogi|larbin|scrubby|searchsight|semanticdiscovery|snappy|zeal(?!ot)|dataparksearch|findlinks|BrowserMob|URL2PNG|ZooShot|GomezA|Google SketchUp|Read%20Later|7Siters|centuryb\.o\.t9|InterNaetBoten|EasyBib AutoCite|Bidtellect|tomnomnom/meg|cortex|Re-re Studio|adreview|AHC/|NameOfAgent|Request-Promise|ALittle Client|Hello,? world|wp_is_mobile|0xAbyssalDoesntExist|Anarchy99|^revolt|nvd0rz|xfa1|Hakai|gbrmss|fuck-your-hp|IDBTE4M CODE87|Antoine|Insomania|Hells-Net|b3astmode|Linux Gnu \(cow\)|Test Certificate Info|iplabel|Magellan|TheSafex?Internetx?Search|Searcherx?web|kirkland-signature|LinkChain|survey-security-dot-txt|infrawatch|Time/|r00ts3c-owned-you|nvdorz|Root Slut|NiggaBalls|BotPoke|GlobalWebSearch|xx032_bo9vs83_2a|sslshed|geckotrail|Wordup|Keydrop|\(compatible\)|John Recon|SPARK COMMIT|masjesu|Komaru_The_Cat|Jesus Christ of Nazareth is LORD|Kowai|Hakai|LoliSec|LMAO|^xenu|^(?:chrome|firefox|Abcd|Dark|KvshClient|Node.js|Report Runner|url|Zeus|ZmEu)$|OnlyScans|TheInternetSearchx' name: 'Generic Bot' diff --git a/regexes/client/hints/browsers.yml b/regexes/client/hints/browsers.yml index 3779076..4178365 100644 --- a/regexes/client/hints/browsers.yml +++ b/regexes/client/hints/browsers.yml @@ -321,7 +321,7 @@ 'com.getkeepsafe.browser': 'Keepsafe Browser' 'com.hawk.android.browser': 'Hawk Turbo Browser' 'com.zte.nubrowser': 'ZTE Browser' -'com.cloaktp.browser': 'Privacy Pioneer Browser' +'com.cloaktp.browser': 'Cloak Private Browser' 'company.thebrowser.arc': 'Arc Search' 'com.android.webview': 'Chrome Webview' 'com.heytap.browser': 'HeyTapBrowser' diff --git a/regexes/client/libraries.yml b/regexes/client/libraries.yml index 698155b..e4b1a0a 100644 --- a/regexes/client/libraries.yml +++ b/regexes/client/libraries.yml @@ -699,3 +699,13 @@ name: 'LibHTTP' version: '$1' url: 'https://www.libhttp.org/' + +- regex: 'BIC Tracker' + name: 'BIC Tracker' + version: '' + url: 'https://github.com/Simbiat/BIC-Tracker' + +- regex: 'Lodestone PHP Parser' + name: 'Lodestone PHP Parser' + version: '' + url: 'https://github.com/Simbiat/lodestone-parser' diff --git a/regexes/client/mobile_apps.yml b/regexes/client/mobile_apps.yml index d789bbb..f88a656 100644 --- a/regexes/client/mobile_apps.yml +++ b/regexes/client/mobile_apps.yml @@ -2680,6 +2680,21 @@ name: 'DeepL' version: '' +# Anything LLM (https://github.com/Mintplex-Labs/anything-llm) +- regex: 'anythingllm-desktop/([\d.]+)' + name: 'Anything LLM' + version: '$1' + +# OP.GG (https://play.google.com/store/apps/details?id=gg.op.lol.android&hl=en_US) +- regex: 'opgg-electron-app/([\d.]+)' + name: 'OP.GG' + version: '$1' + +# VidJuice UniTube (https://www.vidjuice.com/unitube-video-converter/) +- regex: 'VidJuiceUniTube/([\d.]+)' + name: 'VidJuice UniTube' + version: '$1' + # Electron generic apps - regex: ' (?!(?:AppleWebKit|brave|Cypress|Franz|Mailspring|Notion|Basecamp|Evernote|catalyst|ramboxpro|BlueMail|BeakerBrowser|Dezor|TweakStyle|Colibri|Polypane|Singlebox|Skye|VibeMate|(?:d|LT|Glass|Sushi|Flash|OhHai)Browser|Sizzy))([a-z0-9]*)(?:-desktop|-electron-app)?/(\d+\.[\d.]+).*Electron/' name: '$1' diff --git a/regexes/client/pim.yml b/regexes/client/pim.yml index dcdaeaf..ac871af 100644 --- a/regexes/client/pim.yml +++ b/regexes/client/pim.yml @@ -158,7 +158,7 @@ version: '$1' - regex: 'mailapp/(\d+\.[.\d]+)' - name: 'mailapp' + name: 'MailApp' version: '$1' # Gmail diff --git a/regexes/device/mobiles.yml b/regexes/device/mobiles.yml index 6295b43..4de87d2 100644 --- a/regexes/device/mobiles.yml +++ b/regexes/device/mobiles.yml @@ -41621,8 +41621,8 @@ Avaya: device: 'peripheral' model: 'Vantage K175' -# PINE (pine64.org) -PINE: +# PINE64 (pine64.org) +PINE64: regex: 'PINE ([^/;)]+)(?: Build|[);])' device: 'peripheral' model: '$1' diff --git a/spec/device_detector/parser/bot_spec.rb b/spec/device_detector/parser/bot_spec.rb index 568e206..f481f0a 100644 --- a/spec/device_detector/parser/bot_spec.rb +++ b/spec/device_detector/parser/bot_spec.rb @@ -70,7 +70,7 @@ end before do - path = File.expand_path('../../fixtures/custom_regexes/custom_bot.yml', __dir__) + path = File.expand_path('../../support/custom_regexes/custom_bot.yml', __dir__) described_class.add_fixture_path(path) end diff --git a/spec/fixtures/client/browser.yml b/spec/fixtures/client/browser.yml index 27ccb1b..c7e2dd4 100644 --- a/spec/fixtures/client/browser.yml +++ b/spec/fixtures/client/browser.yml @@ -10305,7 +10305,7 @@ user_agent: Mozilla/5.0 (Linux; Android 14; SM-A546V Build/UP1A.231005.007; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.134 Mobile Safari/537.36 client: type: browser - name: Privacy Pioneer Browser + name: Cloak Private Browser version: "" engine: Blink engine_version: 126.0.6478.134 diff --git a/spec/fixtures/client/library.yml b/spec/fixtures/client/library.yml index 726ebaa..34611e0 100644 --- a/spec/fixtures/client/library.yml +++ b/spec/fixtures/client/library.yml @@ -797,3 +797,15 @@ type: library name: LibHTTP version: "1.1" +- + user_agent: BIC Tracker + client: + type: library + name: BIC Tracker + version: "" +- + user_agent: Lodestone PHP Parser + client: + type: library + name: Lodestone PHP Parser + version: "" diff --git a/spec/fixtures/client/mobile_app.yml b/spec/fixtures/client/mobile_app.yml index 1113d62..b9e298a 100644 --- a/spec/fixtures/client/mobile_app.yml +++ b/spec/fixtures/client/mobile_app.yml @@ -2007,19 +2007,19 @@ user_agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_16_0) AppleWebKit/537.36 (KHTML, like Gecko) VidJuiceUniTube/5.0.3 Chrome/85.0.4183.121 Electron/10.4.7 Safari/537.36 client: type: mobile app - name: VidJuiceUniTube + name: VidJuice UniTube version: 5.0.3 - user_agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) opgg-electron-app/1.4.1 Chrome/108.0.5359.215 Electron/22.3.27 Safari/537.36 client: type: mobile app - name: opgg + name: OP.GG version: 1.4.1 - user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) anythingllm-desktop/1.4.1 Chrome/116.0.5845.228 Electron/26.6.1 Safari/537.36 client: type: mobile app - name: anythingllm + name: Anything LLM version: 1.4.1 - user_agent: iPlayTV/3.3.9 (Apple TV; iOS 16.1; Scale/1.00) diff --git a/spec/fixtures/client/pim.yml b/spec/fixtures/client/pim.yml index 867f684..93a2a52 100644 --- a/spec/fixtures/client/pim.yml +++ b/spec/fixtures/client/pim.yml @@ -273,7 +273,7 @@ user_agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 mailapp/6.5.0 client: type: pim - name: mailapp + name: MailApp version: 6.5.0 - user_agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.2pre) Gecko/2009031304 Spicebird/0.7.1 diff --git a/spec/fixtures/detector/bots.yml b/spec/fixtures/detector/bots.yml index 7800584..522fa19 100644 --- a/spec/fixtures/detector/bots.yml +++ b/spec/fixtures/detector/bots.yml @@ -1936,7 +1936,7 @@ - user_agent: Mozilla/5.0 (compatible; MixrankBot; crawler@mixrank.com) bot: - name: Mixrank Bot + name: MixRank Bot category: Crawler url: http://mixrank.com producer: @@ -4120,7 +4120,7 @@ - user_agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.17) Gecko/20110515 HeartRails_Capture/1.0.4 (+http://capture.heartrails.com/) Namoroka/3.6.17 bot: - name: Heart Rails Capture + name: HeartRails Capture category: Service Agent url: http://capture.heartrails.com - @@ -4375,7 +4375,7 @@ bot: name: LumtelBot category: Crawler - url: https://umtel.com + url: https://lumtel.com - user_agent: Mozilla/5.0+(compatible;+PiplBot;+http://www.pipl.com/bot/) bot: @@ -5790,7 +5790,7 @@ bot: name: MoodleBot Linkchecker category: Search bot - url: hhttps://docs.moodle.org/en/Usage + url: https://docs.moodle.org/en/Usage producer: name: Moodle Pty Ltd url: https://moodle.org/ @@ -8671,7 +8671,7 @@ producer: name: Github url: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls -- +- user_agent: Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Bluesky Cardyb/1.1; +mailto:support@bsky.app) Chrome/W.X.Y.Z Safari/537.36 bot: name: Bluesky @@ -8706,3 +8706,12 @@ producer: name: Microsoft Corporation url: https://www.microsoft.com/ +- + user_agent: Simbiat Software + bot: + name: Simbiat Software + category: Crawler + url: https://www.simbiat.eu + producer: + name: Simbiat Software + url: https://www.simbiat.eu diff --git a/spec/fixtures/detector/peripheral.yml b/spec/fixtures/detector/peripheral.yml index 9953d2d..fc7b11d 100644 --- a/spec/fixtures/detector/peripheral.yml +++ b/spec/fixtures/detector/peripheral.yml @@ -872,7 +872,7 @@ engine_version: 75.0.3770.143 device: type: peripheral - brand: PINE + brand: PINE64 model: A64 os_family: Android browser_family: Chrome diff --git a/spec/fixtures/detector/smartphone-39.yml b/spec/fixtures/detector/smartphone-39.yml index 68eb71f..fbcfeb5 100644 --- a/spec/fixtures/detector/smartphone-39.yml +++ b/spec/fixtures/detector/smartphone-39.yml @@ -2283,7 +2283,7 @@ platform: "" client: type: pim - name: mailapp + name: MailApp version: 6.5.3 device: type: smartphone diff --git a/spec/fixtures/custom_regexes/custom_bot.yml b/spec/support/custom_regexes/custom_bot.yml similarity index 100% rename from spec/fixtures/custom_regexes/custom_bot.yml rename to spec/support/custom_regexes/custom_bot.yml diff --git a/spec/support/fixture_loader_helper.rb b/spec/support/fixture_loader_helper.rb index a3e11ec..648779f 100644 --- a/spec/support/fixture_loader_helper.rb +++ b/spec/support/fixture_loader_helper.rb @@ -35,7 +35,7 @@ def yaml_data def load_with_cache if cache_valid? - warn "[YAML Loader] Using json cache: #{CACHE_PATH}" + warn "[YAML Loader] Using json cache: #{CACHE_PATH}\n" return Oj.load_file(CACHE_PATH) end