diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 11dc0f524..00dff869d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,6 +24,7 @@ variables: when: on_failure paths: - tmp/screenshot* + - tmp/screenshots/* - log/*.log # Workaround to enable usage of mixed SSH and Docker GitLab CI runners @@ -67,6 +68,7 @@ stages: pre:rubocop: <<: *pre_stage script: + - bundle install -j $(nproc) - bundle exec rubocop pre:coffeelint: @@ -98,7 +100,7 @@ pre:github: RAILS_ENV: "test" script: - rake zammad:db:init - - bundle exec rspec + - bundle exec rspec -t ~type:system test:rspec:mysql: stage: test @@ -320,6 +322,67 @@ browser:build: - public/assets/application-* - public/assets/print-* +.services_browser_postgresql_template: &services_browser_postgresql_definition + services: + - name: registry.znuny.com/docker/zammad-postgresql:latest + alias: postgresql + - name: registry.znuny.com/docker/zammad-elasticsearch:latest + alias: elasticsearch + - name: docker.io/elgalu/selenium:latest + alias: selenium + - name: registry.znuny.com/docker/docker-imap-devel:latest + alias: mail + +.services_browser_mysql_template: &services_browser_mysql_definition + services: + - name: registry.znuny.com/docker/zammad-mysql:latest + alias: mysql + - name: registry.znuny.com/docker/zammad-elasticsearch:latest + alias: elasticsearch + - name: docker.io/elgalu/selenium:latest + alias: selenium + - name: registry.znuny.com/docker/docker-imap-devel:latest + alias: mail + +## Capybara + +.test_capybara_template: &test_capybara_definition + <<: *base_env + stage: browser-core + script: + - rake zammad:ci:test:prepare[with_elasticsearch] + - bundle exec rspec --fail-fast -t type:system + +.variables_capybara_chrome_template: &variables_capybara_chrome_definition + <<: *test_capybara_definition + variables: + RAILS_ENV: "test" + NO_RESET_BEFORE_SUITE: "true" + BROWSER: "chrome" + +.variables_capybara_ff_template: &variables_capybara_ff_definition + <<: *test_capybara_definition + variables: + RAILS_ENV: "test" + NO_RESET_BEFORE_SUITE: "true" + BROWSER: "firefox" + +test:browser:core:capybara_chrome_postgresql: + <<: *variables_capybara_chrome_definition + <<: *services_browser_postgresql_definition + +test:browser:core:capybara_chrome_mysql: + <<: *variables_capybara_chrome_definition + <<: *services_browser_mysql_definition + +test:browser:core:capybara_ff_postgresql: + <<: *variables_capybara_ff_definition + <<: *services_browser_postgresql_definition + +test:browser:core:capybara_ff_mysql: + <<: *variables_capybara_ff_definition + <<: *services_browser_mysql_definition + ## Browser core tests .variables_browser_template: &variables_browser_definition @@ -387,28 +450,12 @@ test:browser:integration:api_client_php: .test_browser_core_postgresql_template: &test_browser_core_postgresql_definition <<: *test_browser_core_definition <<: *script_browser_slice_definition - services: - - name: registry.znuny.com/docker/zammad-postgresql:latest - alias: postgresql - - name: registry.znuny.com/docker/zammad-elasticsearch:latest - alias: elasticsearch - - name: docker.io/elgalu/selenium:latest - alias: selenium - - name: registry.znuny.com/docker/docker-imap-devel:latest - alias: mail + <<: *services_browser_postgresql_definition .test_browser_core_mysql_template: &test_browser_core_mysql_definition <<: *test_browser_core_definition <<: *script_browser_slice_definition - services: - - name: registry.znuny.com/docker/zammad-mysql:latest - alias: mysql - - name: registry.znuny.com/docker/zammad-elasticsearch:latest - alias: elasticsearch - - name: docker.io/elgalu/selenium:latest - alias: selenium - - name: registry.znuny.com/docker/docker-imap-devel:latest - alias: mail + <<: *services_browser_mysql_definition #### Firefox diff --git a/Gemfile b/Gemfile index cc24e5572..e52605d67 100644 --- a/Gemfile +++ b/Gemfile @@ -145,6 +145,7 @@ group :development, :test do gem 'simplecov-rcov' # UI tests w/ Selenium + gem 'capybara', '~> 2.13' gem 'selenium-webdriver' # livereload on template changes (html, js, css) diff --git a/Gemfile.lock b/Gemfile.lock index d1b30015a..11abf63ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,6 +104,13 @@ GEM buftok (0.2.0) builder (3.2.3) byebug (10.0.2) + capybara (2.18.0) + addressable + mini_mime (>= 0.1.3) + nokogiri (>= 1.3.3) + rack (>= 1.0.0) + rack-test (>= 0.5.4) + xpath (>= 2.0, < 4.0) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) clavius (1.0.3) @@ -255,6 +262,7 @@ GEM mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) + mini_mime (1.0.1) mini_portile2 (2.3.0) minitest (5.11.3) multi_json (1.12.2) @@ -499,6 +507,8 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) writeexcel (1.0.5) + xpath (3.2.0) + nokogiri (~> 1.8) zendesk_api (1.16.0) faraday (~> 0.9) hashie (>= 3.5.2, < 4.0.0) @@ -519,6 +529,7 @@ DEPENDENCIES biz browser byebug + capybara (~> 2.13) clearbit coffee-rails coffee-script-source diff --git a/app/models/scheduler.rb b/app/models/scheduler.rb index 321fd0098..59262fa7f 100644 --- a/app/models/scheduler.rb +++ b/app/models/scheduler.rb @@ -257,13 +257,20 @@ class Scheduler < ApplicationModel else _start_job(job) end - job.pid = '' - job.save - logger.info " ...stopped thread for '#{job.method}'" - ActiveRecord::Base.connection.close - # release thread lock and remove thread handle - @@jobs_started.delete(job.id) + if job.present? + job.pid = '' + job.save + + logger.info " ...stopped thread for '#{job.method}'" + + # release thread lock and remove thread handle + @@jobs_started.delete(job.id) + else + logger.warn ' ...Job got deleted while running' + end + + ActiveRecord::Base.connection.close end end diff --git a/config/puma.rb b/config/puma.rb index bfd115475..946ebb147 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -3,6 +3,8 @@ threads_count_min = Integer(ENV['MIN_THREADS'] || 5) threads_count_max = Integer(ENV['MAX_THREADS'] || 30) threads threads_count_min, threads_count_max +environment ENV.fetch('RAILS_ENV', 'development') + preload_app! on_worker_boot do diff --git a/lib/sessions/event/base.rb b/lib/sessions/event/base.rb index c892bc6fd..7456c6519 100644 --- a/lib/sessions/event/base.rb +++ b/lib/sessions/event/base.rb @@ -12,7 +12,12 @@ class Sessions::Event::Base return if !self.class.instance_variable_get(:@database_connection) - ActiveRecord::Base.establish_connection + if ActiveRecord::Base.connected? + @reused_connection = true + else + @reused_connection = false + ActiveRecord::Base.establish_connection + end end def self.inherited(subclass) @@ -138,6 +143,7 @@ class Sessions::Event::Base def destroy return if !@is_web_socket return if !self.class.instance_variable_get(:@database_connection) + return if @reused_connection ActiveRecord::Base.remove_connection end diff --git a/lib/tasks/zammad/ci/test/fail.rake b/lib/tasks/zammad/ci/test/fail.rake index 1c3bd7c08..ce15ba03d 100644 --- a/lib/tasks/zammad/ci/test/fail.rake +++ b/lib/tasks/zammad/ci/test/fail.rake @@ -5,7 +5,8 @@ namespace :zammad do namespace :test do desc 'Stops all of Zammads services and exists the rake task with exit code 1' - task fail: %i[zammad:ci:test:stop] do + task :fail, [:no_app] do |_task, args| + Rake::Task['zammad:ci:test:stop'].invoke if args[:no_app].blank? abort('Abort further test processing') end end diff --git a/lib/tasks/zammad/ci/test/prepare.rake b/lib/tasks/zammad/ci/test/prepare.rake new file mode 100644 index 000000000..ceec3c520 --- /dev/null +++ b/lib/tasks/zammad/ci/test/prepare.rake @@ -0,0 +1,23 @@ +namespace :zammad do + + namespace :ci do + + namespace :test do + + desc 'Prepares Zammad system for CI env' + task :prepare, [:elasticsearch] do |_task, args| + ENV['RAILS_ENV'] ||= 'production' + ENV['DISABLE_DATABASE_ENVIRONMENT_CHECK'] = 'true' + # we have to enforce the env + # otherwise it will fallback to default (develop) + Rails.env = ENV['RAILS_ENV'] + + Rake::Task['zammad:flush:cache'].invoke + + Rake::Task['zammad:db:init'].invoke + + Rake::Task['zammad:ci:settings'].invoke(args[:elasticsearch]) + end + end + end +end diff --git a/lib/tasks/zammad/ci/test/start.rake b/lib/tasks/zammad/ci/test/start.rake index 646e82adf..f8be78ed5 100644 --- a/lib/tasks/zammad/ci/test/start.rake +++ b/lib/tasks/zammad/ci/test/start.rake @@ -6,17 +6,7 @@ namespace :zammad do desc 'Starts all of Zammads services for CI test' task :start, [:elasticsearch] do |_task, args| - ENV['RAILS_ENV'] ||= 'production' - ENV['DISABLE_DATABASE_ENVIRONMENT_CHECK'] = 'true' - # we have to enforce the env - # otherwise it will fallback to default (develop) - Rails.env = ENV['RAILS_ENV'] - - Rake::Task['zammad:flush:cache'].invoke - - Rake::Task['zammad:db:init'].invoke - - Rake::Task['zammad:ci:settings'].invoke(args[:elasticsearch]) + Rake::Task['zammad:ci:test:prepare'].invoke(args[:elasticsearch]) Rake::Task['zammad:ci:app:start'].invoke end end diff --git a/lib/tasks/zammad/ci/test/stop.rake b/lib/tasks/zammad/ci/test/stop.rake index f76f2d8a2..5487edbc4 100644 --- a/lib/tasks/zammad/ci/test/stop.rake +++ b/lib/tasks/zammad/ci/test/stop.rake @@ -5,7 +5,7 @@ namespace :zammad do namespace :test do desc 'Stop of all Zammad services and cleans up the database(s)' - task :stop do + task :stop, [:no_app] do |_task, args| ENV['RAILS_ENV'] ||= 'production' ENV['DISABLE_DATABASE_ENVIRONMENT_CHECK'] = 'true' @@ -13,7 +13,7 @@ namespace :zammad do # otherwise it will fallback to default (develop) Rails.env = ENV['RAILS_ENV'] - Rake::Task['zammad:ci:app:stop'].invoke + Rake::Task['zammad:ci:app:stop'].invoke if args[:no_app].blank? Rake::Task['db:drop:all'].invoke next if !SearchIndexBackend.enabled? diff --git a/lib/websocket_server.rb b/lib/websocket_server.rb new file mode 100644 index 000000000..648881753 --- /dev/null +++ b/lib/websocket_server.rb @@ -0,0 +1,227 @@ +class WebsocketServer + + cattr_reader :clients, :options + + def self.run(options) + @options = options + @clients = {} + + Rails.configuration.interface = 'websocket' + EventMachine.run do + EventMachine::WebSocket.start( host: @options[:b], port: @options[:p], secure: @options[:s], tls_options: @options[:tls_options] ) do |ws| + + # register client connection + ws.onopen do |handshake| + WebsocketServer.onopen(ws, handshake) + end + + # unregister client connection + ws.onclose do + WebsocketServer.onclose(ws) + end + + # manage messages + ws.onmessage do |msg| + WebsocketServer.onmessage(ws, msg) + end + end + + # check unused connections + EventMachine.add_timer(0.5) do + WebsocketServer.check_unused_connections + end + + # check open unused connections, kick all connection without activitie in the last 2 minutes + EventMachine.add_periodic_timer(120) do + WebsocketServer.check_unused_connections + end + + EventMachine.add_periodic_timer(20) do + WebsocketServer.log_status + end + + EventMachine.add_periodic_timer(0.4) do + WebsocketServer.send_to_client + end + end + end + + def self.onopen(websocket, handshake) + headers = handshake.headers + remote_ip = get_remote_ip(headers) + client_id = websocket.object_id.to_s + log 'notice', 'Client connected.', client_id + Sessions.create( client_id, {}, { type: 'websocket' } ) + + return if @clients.include? client_id + + @clients[client_id] = { + websocket: websocket, + last_ping: Time.now.utc.to_i, + error_count: 0, + headers: headers, + remote_ip: remote_ip, + } + end + + def self.onclose(websocket) + client_id = websocket.object_id.to_s + log 'notice', 'Client disconnected.', client_id + + # removed from current client list + if @clients.include? client_id + @clients.delete client_id + end + + Sessions.destroy(client_id) + end + + def self.onmessage(websocket, msg) + client_id = websocket.object_id.to_s + log 'debug', "received: #{msg} ", client_id + begin + data = JSON.parse(msg) + rescue => e + log 'error', "can't parse message: #{msg}, #{e.inspect}", client_id + return + end + + # check if connection not already exists + return if !@clients[client_id] + + Sessions.touch(client_id) # rubocop:disable Rails/SkipsModelValidations + @clients[client_id][:last_ping] = Time.now.utc.to_i + + # spool messages for new connects + if data['spool'] + Sessions.spool_create(data) + end + + if data['event'] + log 'debug', "execute event '#{data['event']}'", client_id + message = Sessions::Event.run( + event: data['event'], + payload: data, + session: @clients[client_id][:session], + remote_ip: @clients[client_id][:remote_ip], + client_id: client_id, + clients: @clients, + options: @options, + ) + if message + websocket_send(client_id, message) + end + else + log 'error', "unknown message '#{data.inspect}'", client_id + end + end + + def self.get_remote_ip(headers) + return headers['X-Forwarded-For'] if headers && headers['X-Forwarded-For'] + + nil + end + + def self.websocket_send(client_id, data) + msg = if data.class != Array + "[#{data.to_json}]" + else + data.to_json + end + log 'debug', "send #{msg}", client_id + if !@clients[client_id] + log 'error', "no such @clients for #{client_id}", client_id + return + end + @clients[client_id][:websocket].send(msg) + end + + def self.check_unused_connections + log 'notice', 'check unused idle connections...' + + idle_time_in_sec = 4 * 60 + + # close unused web socket sessions + @clients.each do |client_id, client| + + next if ( client[:last_ping].to_i + idle_time_in_sec ) >= Time.now.utc.to_i + + log 'notice', 'closing idle websocket connection', client_id + + # remember to not use this connection anymore + client[:disconnect] = true + + # try to close regular + client[:websocket].close_websocket + + # delete session from client list + sleep 0.3 + @clients.delete(client_id) + end + + # close unused ajax long polling sessions + clients = Sessions.destroy_idle_sessions(idle_time_in_sec) + clients.each do |client_id| + log 'notice', 'closing idle long polling connection', client_id + end + end + + def self.send_to_client + return if @clients.size.zero? + + #log 'debug', 'checking for data to send...' + @clients.each do |client_id, client| + next if client[:disconnect] + + log 'debug', 'checking for data...', client_id + begin + queue = Sessions.queue(client_id) + next if queue.blank? + + log 'notice', 'send data to client', client_id + websocket_send(client_id, queue) + rescue => e + log 'error', 'problem:' + e.inspect, client_id + + # disconnect client + client[:error_count] += 1 + if client[:error_count] > 20 + if @clients.include? client_id + @clients.delete client_id + end + end + end + end + end + + def self.log_status + # websocket + log 'notice', "Status: websocket clients: #{@clients.size}" + @clients.each_key do |client_id| + log 'notice', 'working...', client_id + end + + # ajax + client_list = Sessions.list + clients = 0 + client_list.each_value do |client| + next if client[:meta][:type] == 'websocket' + + clients = clients + 1 + end + log 'notice', "Status: ajax clients: #{clients}" + client_list.each do |client_id, client| + next if client[:meta][:type] == 'websocket' + + log 'notice', 'working...', client_id + end + end + + def self.log(level, data, client_id = '-') + if !@options[:v] + return if level == 'debug' + end + puts "#{Time.now.utc.iso8601}:client(#{client_id}) #{data}" # rubocop:disable Rails/Output + #puts "#{Time.now.utc.iso8601}:#{ level }:client(#{ client_id }) #{ data }" + end +end diff --git a/script/websocket-server.rb b/script/websocket-server.rb index 53fde9ea3..633df6f18 100755 --- a/script/websocket-server.rb +++ b/script/websocket-server.rb @@ -87,10 +87,12 @@ OptionParser.new do |opts| @options[:i] = i end opts.on('-k', '--private-key [OPT]', '/path/to/server.key for secure connections') do |k| - tls_options[:private_key_file] = k + options[:tls_options] ||= {} + options[:tls_options][:private_key_file] = k end opts.on('-c', '--certificate [OPT]', '/path/to/server.crt for secure connections') do |c| - tls_options[:cert_chain_file] = c + options[:tls_options] ||= {} + options[:tls_options][:cert_chain_file] = c end end.parse! @@ -125,205 +127,4 @@ if ARGV[0] == 'start' && @options[:d] after_fork(dir) end -@clients = {} -Rails.configuration.interface = 'websocket' -EventMachine.run do - EventMachine::WebSocket.start( host: @options[:b], port: @options[:p], secure: @options[:s], tls_options: tls_options ) do |ws| - - # register client connection - ws.onopen do |handshake| - headers = handshake.headers - remote_ip = get_remote_ip(headers) - client_id = ws.object_id.to_s - log 'notice', 'Client connected.', client_id - Sessions.create( client_id, {}, { type: 'websocket' } ) - - if !@clients.include? client_id - @clients[client_id] = { - websocket: ws, - last_ping: Time.now.utc.to_i, - error_count: 0, - headers: headers, - remote_ip: remote_ip, - } - end - end - - # unregister client connection - ws.onclose do - client_id = ws.object_id.to_s - log 'notice', 'Client disconnected.', client_id - - # removed from current client list - if @clients.include? client_id - @clients.delete client_id - end - - Sessions.destroy(client_id) - end - - # manage messages - ws.onmessage do |msg| - - client_id = ws.object_id.to_s - log 'debug', "received: #{msg} ", client_id - begin - data = JSON.parse(msg) - rescue => e - log 'error', "can't parse message: #{msg}, #{e.inspect}", client_id - next - end - - # check if connection not already exists - next if !@clients[client_id] - - Sessions.touch(client_id) # rubocop:disable Rails/SkipsModelValidations - @clients[client_id][:last_ping] = Time.now.utc.to_i - - # spool messages for new connects - if data['spool'] - Sessions.spool_create(data) - end - - if data['event'] - log 'debug', "execute event '#{data['event']}'", client_id - message = Sessions::Event.run( - event: data['event'], - payload: data, - session: @clients[client_id][:session], - remote_ip: @clients[client_id][:remote_ip], - client_id: client_id, - clients: @clients, - options: @options, - ) - if message - websocket_send(client_id, message) - end - else - log 'error', "unknown message '#{data.inspect}'", client_id - end - end - end - - # check unused connections - EventMachine.add_timer(0.5) do - check_unused_connections - end - - # check open unused connections, kick all connection without activitie in the last 2 minutes - EventMachine.add_periodic_timer(120) do - check_unused_connections - end - - EventMachine.add_periodic_timer(20) do - - # websocket - log 'notice', "Status: websocket clients: #{@clients.size}" - @clients.each_key do |client_id| - log 'notice', 'working...', client_id - end - - # ajax - client_list = Sessions.list - clients = 0 - client_list.each_value do |client| - next if client[:meta][:type] == 'websocket' - - clients = clients + 1 - end - log 'notice', "Status: ajax clients: #{clients}" - client_list.each do |client_id, client| - next if client[:meta][:type] == 'websocket' - - log 'notice', 'working...', client_id - end - - end - - EventMachine.add_periodic_timer(0.4) do - next if @clients.size.zero? - - #log 'debug', 'checking for data to send...' - @clients.each do |client_id, client| - next if client[:disconnect] - - log 'debug', 'checking for data...', client_id - begin - queue = Sessions.queue(client_id) - next if queue.blank? - - log 'notice', 'send data to client', client_id - websocket_send(client_id, queue) - rescue => e - log 'error', 'problem:' + e.inspect, client_id - - # disconnect client - client[:error_count] += 1 - if client[:error_count] > 20 - if @clients.include? client_id - @clients.delete client_id - end - end - end - end - end - - def get_remote_ip(headers) - return headers['X-Forwarded-For'] if headers && headers['X-Forwarded-For'] - - nil - end - - def websocket_send(client_id, data) - msg = if data.class != Array - "[#{data.to_json}]" - else - data.to_json - end - log 'debug', "send #{msg}", client_id - if !@clients[client_id] - log 'error', "no such @clients for #{client_id}", client_id - return - end - @clients[client_id][:websocket].send(msg) - end - - def check_unused_connections - log 'notice', 'check unused idle connections...' - - idle_time_in_sec = 4 * 60 - - # close unused web socket sessions - @clients.each do |client_id, client| - - next if ( client[:last_ping].to_i + idle_time_in_sec ) >= Time.now.utc.to_i - - log 'notice', 'closing idle websocket connection', client_id - - # remember to not use this connection anymore - client[:disconnect] = true - - # try to close regular - client[:websocket].close_websocket - - # delete session from client list - sleep 0.3 - @clients.delete(client_id) - end - - # close unused ajax long polling sessions - clients = Sessions.destroy_idle_sessions(idle_time_in_sec) - clients.each do |client_id| - log 'notice', 'closing idle long polling connection', client_id - end - end - - def log(level, data, client_id = '-') - if !@options[:v] - return if level == 'debug' - end - puts "#{Time.now.utc.iso8601}:client(#{client_id}) #{data}" - #puts "#{Time.now.utc.iso8601}:#{ level }:client(#{ client_id }) #{ data }" - end - -end +WebsocketServer.run(@options) diff --git a/spec/support/capybara/authenticated.rb b/spec/support/capybara/authenticated.rb new file mode 100644 index 000000000..394280e2b --- /dev/null +++ b/spec/support/capybara/authenticated.rb @@ -0,0 +1,25 @@ +# This file registers a hook before each system test +# which logs in with/authenticates the master@example.com account. + +# we need to make sure that Capybara is configured/started before +# this hook. Otherwise a login try is performed while the app/puma +# hasn't started yet. +require_relative './driven_by' + +RSpec.configure do |config| + + config.before(:each, type: :system) do |example| + + # there is no way to authenticated in a not set up system + next if !example.metadata.fetch(:set_up, true) + + # check if authentication should be performed + next if example.metadata.fetch(:authenticated, true).blank? + + # authenticate + login( + username: 'master@example.com', + password: 'test', + ) + end +end diff --git a/spec/support/capybara/browser_test_helper.rb b/spec/support/capybara/browser_test_helper.rb new file mode 100644 index 000000000..1cdc30a0f --- /dev/null +++ b/spec/support/capybara/browser_test_helper.rb @@ -0,0 +1,72 @@ +module BrowserTestHelper + + # Finds an element and clicks it - wrapped in one method. + # + # @example + # click('.js-channel .btn.email') + # + # click(:href, '#settings/branding') + # + def click(*args) + find(*args).click + end + + # This is a wrapper around the Selenium::WebDriver::Wait class + # with additional methods. + # @see BrowserTestHelper::Waiter + # + # @example + # wait(5).until { ... } + # + # @example + # wait(5, interval: 0.5).until { ... } + # + def wait(seconds = Capybara.default_max_wait_time, **kargs) + wait_args = Hash(kargs).merge(timeout: seconds) + wait_handle = Selenium::WebDriver::Wait.new(wait_args) + Waiter.new(wait_handle) + end + + class Waiter < SimpleDelegator + + # This method is a derivation of Selenium::WebDriver::Wait#until + # which ignores Capybara::ElementNotFound exceptions raised + # in the given block. + # + # @example + # wait(5).until_exists { find('[data-title="example"]') } + # + def until_exists + self.until do + begin + yield + rescue Capybara::ElementNotFound # rubocop:disable Lint/HandleExceptions + end + end + rescue Selenium::WebDriver::Error::TimeOutError => e + # cleanup backtrace + e.set_backtrace(e.backtrace.drop(3)) + raise e + end + + # This method loops a given block until the result of it is constant. + # + # @example + # wait(5).until_constant { find('.total').text } + # + def until_constant + previous = nil + loop do + sleep __getobj__.instance_variable_get(:@interval) + latest = yield + break if latest == previous + + previous = latest + end + end + end +end + +RSpec.configure do |config| + config.include BrowserTestHelper, type: :system +end diff --git a/spec/support/capybara/common_actions.rb b/spec/support/capybara/common_actions.rb new file mode 100644 index 000000000..c51122a8f --- /dev/null +++ b/spec/support/capybara/common_actions.rb @@ -0,0 +1,131 @@ +module CommonActions + + delegate :app_host, to: Capybara + + # Performs a login with the given credentials and closes the clues (if present). + # The 'remember me' can optionally be checked. + # + # @example + # login( + # username: 'master@example.com', + # password: 'test', + # ) + # + # @example + # login( + # username: 'master@example.com', + # password: 'test', + # remember_me: true, + # ) + # + # return [nil] + def login(username:, password:, remember_me: false) + visit '/' + + within('#login') do + fill_in 'username', with: username + fill_in 'password', with: password + + # check via label because checkbox is hidden + click('.checkbox-replacement') if remember_me + + # submit + click_button + end + + wait(4).until_exists do + current_login + end + + return if User.find_by(login: current_login).preferences[:intro] + + find(:clues_close, wait: 3).in_fixed_postion.click + end + + # Checks if the current session is logged in. + # + # @example + # logged_in? + # => true + # + # @return [true, false] + def logged_in? + current_login.present? + rescue Capybara::ElementNotFound + false + end + + # Returns the login of the currently logged in user. + # + # @example + # current_login + # => 'master@example.com' + # + # @return [String] the login of the currently logged in user. + def current_login + find('.user-menu .user a')[:title] + end + + # Logs out the currently logged in user. + # + # @example + # logout + # + def logout + visit('logout') + end + + # Overwrites the Capybara::Session#visit method to allow SPA navigation. + # All routes not starting with `/` will be handled as SPA routes. + # + # @see Capybara::Session#visit + # + # @example + # visit('logout') + # => visited SPA route '/#logout' + # + # @example + # visit('/test/ui') + # => visited regular route '/test/ui' + # + def visit(route) + if !route.start_with?('/') + route = "/##{route}" + end + super(route) + end + + # This method is equivalent to Capybara::RSpecMatchers#have_current_path + # but checks the SPA route instead of the actual path. + # + # @see Capybara::RSpecMatchers#have_current_path + # + # @example + # expect(page).to have_current_route('login') + # => checks for SPA route '/#login' + # + def have_current_route(route, **options) + if route.is_a?(String) + route = Regexp.new(Regexp.quote("/##{route}")) + end + + options.reverse_merge!(wait: 0, url: true) + + have_current_path("/##{route}", **options) + end + + # This is a convenient wrapper method around #have_current_route + # which requires no previous `expect(page).to ` call. + # + # @example + # expect_current_routes('login') + # => checks for SPA route '/#login' + # + def expect_current_route(route, **options) + expect(page).to have_current_route(route, **options) + end +end + +RSpec.configure do |config| + config.include CommonActions, type: :system +end diff --git a/spec/support/capybara/config.rb b/spec/support/capybara/config.rb new file mode 100644 index 000000000..959b9f525 --- /dev/null +++ b/spec/support/capybara/config.rb @@ -0,0 +1,4 @@ +Capybara.configure do |config| + config.always_include_port = true + config.default_max_wait_time = 16 +end diff --git a/spec/support/capybara/custom_extensions.rb b/spec/support/capybara/custom_extensions.rb new file mode 100644 index 000000000..65dca76a9 --- /dev/null +++ b/spec/support/capybara/custom_extensions.rb @@ -0,0 +1,34 @@ +class Capybara::Node::Element + + # This is an extension to each node to check if the element + # is moving or in a fixed position. This is especially helpful + # for animated elements that cause flanky tests. + # NOTE: In CI env a special sleep is performed between checks + # because animations can be slow. + # + # @param [Integer] checks the number of performed movement checks + # + # @example + # find('.clues-close').in_fixed_postion.click + # => waits till clues moved to final position and performs click afterwards + # + # @raise [RuntimeError] raised in case the element is + # still moving after max number of checks was passed + # + # @return [Capybara::Node::Element] the element/node + def in_fixed_postion(checks: 100) + + previous = native.location + (checks + 1).times do |check| + raise "Element still moving after #{checks} checks" if check == checks + + current = native.location + sleep 0.2 if ENV['CI'] + break if previous == current + + previous = current + end + + self + end +end diff --git a/spec/support/capybara/driven_by.rb b/spec/support/capybara/driven_by.rb new file mode 100644 index 000000000..fd41ec746 --- /dev/null +++ b/spec/support/capybara/driven_by.rb @@ -0,0 +1,19 @@ +require_relative './set_up' + +RSpec.configure do |config| + config.before(:each, type: :system) do + + # start a silenced Puma as application server + Capybara.server = :puma, { Silent: true, Host: '0.0.0.0' } + + # set the Host from gather container IP for CI runs + if ENV['CI'].present? + ip_address = Socket.ip_address_list.detect(&:ipv4_private?).ip_address + host!("http://#{ip_address}") + end + + # set custom Zammad driver (e.g. zammad_chrome) for special + # functionalities and CI requirements + driven_by("zammad_#{ENV.fetch('BROWSER', 'firefox')}".to_sym) + end +end diff --git a/spec/support/capybara/selectors.rb b/spec/support/capybara/selectors.rb new file mode 100644 index 000000000..58bb2fccb --- /dev/null +++ b/spec/support/capybara/selectors.rb @@ -0,0 +1,21 @@ +# This file defines custom Capybara selectors for DRYed specs. + +Capybara.add_selector(:href) do + css { |href| %(a[href="#{href}"]) } +end + +Capybara.add_selector(:active_content) do + css { |content_class| ['.content.active', content_class].compact.join(' ') } +end + +Capybara.add_selector(:manage) do + css { 'a[href="#manage"]' } +end + +Capybara.add_selector(:clues_close) do + css { '.js-modal--clue .js-close' } +end + +Capybara.add_selector(:richtext) do + css { |name| "div[data-name=#{name}]" } +end diff --git a/spec/support/capybara/selenium_driver.rb b/spec/support/capybara/selenium_driver.rb new file mode 100644 index 000000000..9c91013a0 --- /dev/null +++ b/spec/support/capybara/selenium_driver.rb @@ -0,0 +1,49 @@ +# This file registers the custom Zammad chrome and firefox drivers. +# The options check if a REMOTE_URL ENV is given and change the +# configurations accordingly. + +Capybara.register_driver(:zammad_chrome) do |app| + + # Turn on browser logs + capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( + loggingPrefs: { + browser: 'ALL' + }, + ) + + options = { + browser: :chrome, + desired_capabilities: capabilities, + } + + if ENV['REMOTE_URL'].present? + options[:browser] = :remote + options[:url] = ENV['REMOTE_URL'] + end + + Capybara::Selenium::Driver.new(app, options) +end + +Capybara.register_driver(:zammad_firefox) do |app| + + profile = Selenium::WebDriver::Firefox::Profile.new + profile['intl.locale.matchOS'] = false + profile['intl.accept_languages'] = 'en-US' + profile['general.useragent.locale'] = 'en-US' + + capabilities = Selenium::WebDriver::Remote::Capabilities.firefox( + firefox_profile: profile, + ) + + options = { + browser: :firefox, + desired_capabilities: capabilities, + } + + if ENV['REMOTE_URL'].present? + options[:browser] = :remote + options[:url] = ENV['REMOTE_URL'] + end + + Capybara::Selenium::Driver.new(app, options) +end diff --git a/spec/support/capybara/set_up.rb b/spec/support/capybara/set_up.rb new file mode 100644 index 000000000..f197a88c3 --- /dev/null +++ b/spec/support/capybara/set_up.rb @@ -0,0 +1,24 @@ +RSpec.configure do |config| + config.before(:each, type: :system) do |example| + + # make sure system is in a fresh state + Cache.clear + Setting.reload + + # check if system is already set up + next if Setting.get('system_init_done') + + # check if system should get set up + next if !example.metadata.fetch(:set_up, true) + + # perform setup via auto_wizard + Rake::Task['zammad:setup:auto_wizard'].execute + + # skip intro/clues for created agents/admins + %w[master@example.com agent1@example.com].each do |login| + user = User.find_by(login: login) + user.preferences[:intro] = true + user.save! + end + end +end diff --git a/spec/support/capybara/websocket_server.rb b/spec/support/capybara/websocket_server.rb new file mode 100644 index 000000000..1182589f8 --- /dev/null +++ b/spec/support/capybara/websocket_server.rb @@ -0,0 +1,24 @@ +RSpec.configure do |config| + config.around(:each, type: :system) do |example| + + server_required = example.metadata.fetch(:websocket, true) + + if server_required + websocket_server = Thread.new do + WebsocketServer.run( + p: ENV['WS_PORT'] || 6042, + b: '0.0.0.0', + s: false, + v: false, + d: false, + ) + end + end + + example.run + + next if !server_required + + Thread.kill(websocket_server) + end +end diff --git a/spec/system/basic/authentication_spec.rb b/spec/system/basic/authentication_spec.rb new file mode 100644 index 000000000..720b14451 --- /dev/null +++ b/spec/system/basic/authentication_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe 'Authentication', type: :system do + + scenario 'Login', authenticated: false do + login( + username: 'master@example.com', + password: 'test', + ) + + have_current_route 'dashboard' + end + + scenario 'Logout' do + logout + have_current_route 'login', wait: 2 + end +end diff --git a/spec/system/basic/redirects_spec.rb b/spec/system/basic/redirects_spec.rb new file mode 100644 index 000000000..bd28238a1 --- /dev/null +++ b/spec/system/basic/redirects_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe 'Unauthenticated redirect', type: :system, authenticated: false do + + scenario 'Sessions' do + visit 'system/sessions' + have_current_route 'login' + end + + scenario 'Profile' do + visit 'profile/linked' + have_current_route 'login' + end + + scenario 'Ticket' do + visit 'ticket/zoom/1' + have_current_route 'login' + end + + scenario 'Not existing route' do + visit 'not_existing' + have_current_route 'not_existing' + end +end diff --git a/spec/system/basic/richtext_spec.rb b/spec/system/basic/richtext_spec.rb new file mode 100644 index 000000000..ac5c2c552 --- /dev/null +++ b/spec/system/basic/richtext_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +RSpec.describe 'Richtext', type: :system do + + before(:each) do + click(:href, '#current_user') + click(:href, '#layout_ref') + click(:href, '#layout_ref/richtext') + end + + context 'Richtext' do + + scenario 'Single line mode' do + + element = find('#content .text-1') + + element.send_keys( + 'some test for browser ', + :enter, + 'and some other for browser' + ) + + expect(element).to have_content('some test for browser and some other for browser') + end + + scenario 'Multi line mode' do + + element = find('#content .text-5') + + element.send_keys( + 'some test for browser ', + :enter, + 'and some other for browser' + ) + + expect(element).to have_content("some test for browser \nand some other for browser") + end + end + + context 'Regular text' do + + scenario 'Multi line mode' do + + element = find('#content .text-3') + + element.send_keys( + 'some test for browser ', + :enter, + 'and some other for browser' + ) + + expect(element).to have_content("some test for browser \nand some other for browser") + end + end +end diff --git a/spec/system/js/q_unit_spec.rb b/spec/system/js/q_unit_spec.rb new file mode 100644 index 000000000..53da6d9b1 --- /dev/null +++ b/spec/system/js/q_unit_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe 'QUnit', type: :system, authenticated: false, set_up: true, websocket: false do + + def q_unit_tests(test_name) + + visit "/tests_#{test_name}" + + yield if block_given? + + expect(page).to have_css('.result', text: 'Tests completed') + expect(page).to have_css('.result .failed', text: '0') + end + + def async_q_unit_tests(*args) + q_unit_tests(*args) do + wait(10, interval: 4).until_constant do + find('.total').text + end + end + end + + scenario 'Core' do + async_q_unit_tests('core') + end + + context 'UI' do + + scenario 'Base' do + q_unit_tests('ui') + end + + scenario 'Model' do + async_q_unit_tests('model') + end + + scenario 'Model binding' do + q_unit_tests('model_binding') + end + + scenario 'Model UI' do + + if !ENV['CI'] + skip("Can't run locally because of dependence of special Timezone") + end + + q_unit_tests('model_ui') + end + + scenario 'Ticket selector' do + q_unit_tests('ticket_selector') + end + end + + context 'Form' do + + scenario 'Base' do + async_q_unit_tests('form') + end + + scenario 'Trim' do + q_unit_tests('form_trim') + end + + scenario 'Find' do + q_unit_tests('form_find') + end + + scenario 'Timer' do + q_unit_tests('form_timer') + end + + scenario 'Extended' do + q_unit_tests('form_extended') + end + + scenario 'Searchable select' do + q_unit_tests('form_searchable_select') + end + + scenario 'Tree select' do + q_unit_tests('form_tree_select') + end + + scenario 'Column select' do + q_unit_tests('form_column_select') + end + + scenario 'Validation' do + q_unit_tests('form_validation') + end + end + + context 'Table' do + + scenario 'Base' do + q_unit_tests('table') + end + + scenario 'Extended' do + q_unit_tests('table_extended') + end + + scenario 'HTML utils' do + q_unit_tests('html_utils') + end + + scenario 'Taskbar' do + q_unit_tests('taskbar') + end + end +end diff --git a/spec/system/setup/auto_wizard_spec.rb b/spec/system/setup/auto_wizard_spec.rb new file mode 100644 index 000000000..03ad9f16b --- /dev/null +++ b/spec/system/setup/auto_wizard_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe 'Auto wizard', type: :system, set_up: false do + + scenario 'Automatic setup and login' do + + FileUtils.ln( + Rails.root.join('contrib', 'auto_wizard_test.json'), + Rails.root.join('auto_wizard.json'), + force: true + ) + + visit 'getting_started/auto_wizard' + + expect(current_login).to eq('master@example.com') + end +end diff --git a/spec/system/setup/mail_accounts_spec.rb b/spec/system/setup/mail_accounts_spec.rb new file mode 100644 index 000000000..32687338f --- /dev/null +++ b/spec/system/setup/mail_accounts_spec.rb @@ -0,0 +1,109 @@ +require 'rails_helper' + +RSpec.describe 'Mail accounts', type: :system do + + def perform_check + # getting started - auto mail + visit 'getting_started/channel' + + click('.js-channel .btn.email') + + yield + + # wait for verification process to finish + expect(page).to have_css('.js-agent h2', text: 'Invite Colleagues', wait: 4.minutes) + + have_current_route 'getting_started/agents' + end + + def fill_in_credentials(account) + within('.js-intro') do + + fill_in 'realname', with: account[:realname] + fill_in 'email', with: account[:email] + fill_in 'password', with: account[:password] + + click_on('Connect') + end + end + + scenario 'Auto detectable configurations' do + + skip('NOTICE: This test is currently disabled because of collisions with other non Capybara browser tests') + + accounts = (1..10).each_with_object([]) do |count, result| + next if !ENV["MAILBOX_AUTO#{count}"] + + email, password = ENV["MAILBOX_AUTO#{count}"].split(':') + result.push( + realname: 'auto account', + email: email, + password: password, + ) + end + + if accounts.blank? + skip("NOTICE: Need min. MAILBOX_AUTO1 as ENV variable like export MAILBOX_AUTO1='nicole.braun2015@gmail.com:somepass'") + end + + accounts.each do |account| + + perform_check do + fill_in_credentials(account) + end + end + end + + scenario 'Manual configurations' do + + accounts = (1..10).each_with_object([]) do |count, result| + next if !ENV["MAILBOX_MANUAL#{count}"] + + email, password, inbound, outbound = ENV["MAILBOX_MANUAL#{count}"].split(':') + + result.push( + realname: 'manual account', + email: email, + password: password, + inbound: { + 'options::host' => inbound, + }, + outbound: { + 'options::host' => outbound, + }, + ) + end + + if accounts.blank? + skip("NOTICE: Need min. MAILBOX_MANUAL1 as ENV variable like export MAILBOX_MANUAL1='nicole.bauer2015@yahoo.de:somepass:imap.mail.yahoo.com:smtp.mail.yahoo.com'") + end + + accounts.each do |account| + + perform_check do + fill_in_credentials(account) + + within('.js-inbound') do + + expect(page).to have_css('h2', text: 'inbound', wait: 4.minutes) + expect(page).to have_css('body', text: 'manual') + + fill_in 'options::host', with: account[:inbound]['options::host'] + + click_on('Connect') + end + + within('.js-outbound') do + + expect(page).to have_css('h2', text: 'outbound', wait: 4.minutes) + + select('SMTP - configure your own outgoing SMTP settings', from: 'adapter') + + fill_in 'options::host', with: account[:outbound]['options::host'] + + click_on('Connect') + end + end + end + end +end diff --git a/spec/system/setup/system_spec.rb b/spec/system/setup/system_spec.rb new file mode 100644 index 000000000..806737345 --- /dev/null +++ b/spec/system/setup/system_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe 'System setup process', type: :system, set_up: false do + + def fqdn + match_data = %r{://(.+?)(:.+?|/.+?|)$}.match(app_host) + return match_data.captures.first if match_data.present? + + raise "Unable to get fqdn based on #{app_host}" + end + + scenario 'Setting up a new system', authenticated: false do + + if !ENV['MAILBOX_INIT'] + skip("NOTICE: Need MAILBOX_INIT as ENV variable like export MAILBOX_INIT='unittest01@znuny.com:somepass'") + end + mailbox_user = ENV['MAILBOX_INIT'].split(':')[0] + mailbox_password = ENV['MAILBOX_INIT'].split(':')[1] + + visit '/' + + expect(page).to have_css('.setup.wizard', text: 'Setup new System') + + # choose setup (over migration) + click_on('Setup new System') + + # admin user form + expect(page).to have_css('.js-admin h2', text: 'Administrator Account') + + within('.js-admin') do + fill_in 'firstname', with: 'Test Master' + fill_in 'lastname', with: 'Agent' + fill_in 'email', with: 'master@example.com' + fill_in 'password', with: 'test1234äöüß' + fill_in 'password_confirm', with: 'test1234äöüß' + + click_on('Create') + end + + # configure Organization + expect(page).to have_css('.js-base h2', text: 'Organization') + within('.js-base') do + fill_in 'organization', with: 'Some Organization' + + # fill in wrong URL + fill_in 'url', with: 'some host' + click_on('Next') + expect(page).to have_css('.alert', text: 'A URL looks like') + + # fill in valild/current URL + fill_in 'url', with: app_host + click_on('Next') + end + + # configure Email Notification + expect(page).to have_css('.js-outbound h2', text: 'Email Notification') + have_current_route 'getting_started/email_notification' + click_on('Continue') + + # create email account + expect(page).to have_css('.js-channel h2', text: 'Connect Channels') + have_current_route 'getting_started/channel' + click('.js-channel .btn.email') + + within('.js-intro') do + fill_in 'realname', with: 'Some Realname' + fill_in 'email', with: mailbox_user + fill_in 'password', with: mailbox_password + + click_on('Connect') + end + + # wait for verification process to start + expect(page).to have_css('body', text: 'Verify sending and receiving', wait: 20) + + # wait for verification process to finish + expect(page).to have_css('.js-agent h2', text: 'Invite Colleagues', wait: 2.minutes) + have_current_route 'getting_started/agents' + + # invite agent1 + within('.js-agent') do + fill_in 'firstname', with: 'Agent 1' + fill_in 'lastname', with: 'Test' + fill_in 'email', with: 'agent12@example.com' + + click_on('Invite') + end + expect(page).to have_css('body', text: 'Invitation sent!') + + # expect to still be on the same page + have_current_route 'getting_started/agents' + within('.js-agent') do + click_on('Continue') + end + + # expect Dashboard of a fresh system + expect(page).to have_css('body', text: 'My Stats') + have_current_route 'clues' + find(:clues_close, wait: 4).in_fixed_postion.click + + # verify organization and fqdn + click(:manage) + + within(:active_content) do + + click(:href, '#settings/branding') + expect(page).to have_field('organization', with: 'Some Organization') + + click(:href, '#settings/system') + expect(page).to have_field('fqdn', with: fqdn) + end + end +end diff --git a/test/unit/chat_test.rb b/test/unit/chat_test.rb index c9f4e4fe0..48af672a9 100644 --- a/test/unit/chat_test.rb +++ b/test/unit/chat_test.rb @@ -58,6 +58,9 @@ class ChatTest < ActiveSupport::TestCase # with websockets assert(User.first) + # make sure to emulate unconnected WS env + ActiveRecord::Base.remove_connection + message = Sessions::Event.run( event: 'login', payload: {},