Merge branch 'develop' into private-te_rubocop_update

This commit is contained in:
Thorsten Eckel 2018-12-20 09:16:35 +01:00
commit 76dfdbaf83
30 changed files with 1214 additions and 244 deletions

View file

@ -24,6 +24,7 @@ variables:
when: on_failure when: on_failure
paths: paths:
- tmp/screenshot* - tmp/screenshot*
- tmp/screenshots/*
- log/*.log - log/*.log
# Workaround to enable usage of mixed SSH and Docker GitLab CI runners # Workaround to enable usage of mixed SSH and Docker GitLab CI runners
@ -67,6 +68,7 @@ stages:
pre:rubocop: pre:rubocop:
<<: *pre_stage <<: *pre_stage
script: script:
- bundle install -j $(nproc)
- bundle exec rubocop - bundle exec rubocop
pre:coffeelint: pre:coffeelint:
@ -98,7 +100,7 @@ pre:github:
RAILS_ENV: "test" RAILS_ENV: "test"
script: script:
- rake zammad:db:init - rake zammad:db:init
- bundle exec rspec - bundle exec rspec -t ~type:system
test:rspec:mysql: test:rspec:mysql:
stage: test stage: test
@ -320,6 +322,67 @@ browser:build:
- public/assets/application-* - public/assets/application-*
- public/assets/print-* - 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 ## Browser core tests
.variables_browser_template: &variables_browser_definition .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_postgresql_template: &test_browser_core_postgresql_definition
<<: *test_browser_core_definition <<: *test_browser_core_definition
<<: *script_browser_slice_definition <<: *script_browser_slice_definition
services: <<: *services_browser_postgresql_definition
- 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
.test_browser_core_mysql_template: &test_browser_core_mysql_definition .test_browser_core_mysql_template: &test_browser_core_mysql_definition
<<: *test_browser_core_definition <<: *test_browser_core_definition
<<: *script_browser_slice_definition <<: *script_browser_slice_definition
services: <<: *services_browser_mysql_definition
- 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
#### Firefox #### Firefox

View file

@ -145,6 +145,7 @@ group :development, :test do
gem 'simplecov-rcov' gem 'simplecov-rcov'
# UI tests w/ Selenium # UI tests w/ Selenium
gem 'capybara', '~> 2.13'
gem 'selenium-webdriver' gem 'selenium-webdriver'
# livereload on template changes (html, js, css) # livereload on template changes (html, js, css)

View file

@ -104,6 +104,13 @@ GEM
buftok (0.2.0) buftok (0.2.0)
builder (3.2.3) builder (3.2.3)
byebug (10.0.2) 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) childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11) ffi (~> 1.0, >= 1.0.11)
clavius (1.0.3) clavius (1.0.3)
@ -255,6 +262,7 @@ GEM
mime-types (3.2.2) mime-types (3.2.2)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812) mime-types-data (3.2018.0812)
mini_mime (1.0.1)
mini_portile2 (2.3.0) mini_portile2 (2.3.0)
minitest (5.11.3) minitest (5.11.3)
multi_json (1.12.2) multi_json (1.12.2)
@ -499,6 +507,8 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3) websocket-extensions (0.1.3)
writeexcel (1.0.5) writeexcel (1.0.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zendesk_api (1.16.0) zendesk_api (1.16.0)
faraday (~> 0.9) faraday (~> 0.9)
hashie (>= 3.5.2, < 4.0.0) hashie (>= 3.5.2, < 4.0.0)
@ -519,6 +529,7 @@ DEPENDENCIES
biz biz
browser browser
byebug byebug
capybara (~> 2.13)
clearbit clearbit
coffee-rails coffee-rails
coffee-script-source coffee-script-source

View file

@ -257,13 +257,20 @@ class Scheduler < ApplicationModel
else else
_start_job(job) _start_job(job)
end end
job.pid = ''
job.save
logger.info " ...stopped thread for '#{job.method}'"
ActiveRecord::Base.connection.close
# release thread lock and remove thread handle if job.present?
@@jobs_started.delete(job.id) 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
end end

View file

@ -3,6 +3,8 @@ threads_count_min = Integer(ENV['MIN_THREADS'] || 5)
threads_count_max = Integer(ENV['MAX_THREADS'] || 30) threads_count_max = Integer(ENV['MAX_THREADS'] || 30)
threads threads_count_min, threads_count_max threads threads_count_min, threads_count_max
environment ENV.fetch('RAILS_ENV', 'development')
preload_app! preload_app!
on_worker_boot do on_worker_boot do

View file

@ -12,7 +12,12 @@ class Sessions::Event::Base
return if !self.class.instance_variable_get(:@database_connection) 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 end
def self.inherited(subclass) def self.inherited(subclass)
@ -138,6 +143,7 @@ class Sessions::Event::Base
def destroy def destroy
return if !@is_web_socket return if !@is_web_socket
return if !self.class.instance_variable_get(:@database_connection) return if !self.class.instance_variable_get(:@database_connection)
return if @reused_connection
ActiveRecord::Base.remove_connection ActiveRecord::Base.remove_connection
end end

View file

@ -5,7 +5,8 @@ namespace :zammad do
namespace :test do namespace :test do
desc 'Stops all of Zammads services and exists the rake task with exit code 1' 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') abort('Abort further test processing')
end end
end end

View file

@ -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

View file

@ -6,17 +6,7 @@ namespace :zammad do
desc 'Starts all of Zammads services for CI test' desc 'Starts all of Zammads services for CI test'
task :start, [:elasticsearch] do |_task, args| task :start, [:elasticsearch] do |_task, args|
ENV['RAILS_ENV'] ||= 'production' Rake::Task['zammad:ci:test:prepare'].invoke(args[:elasticsearch])
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:app:start'].invoke Rake::Task['zammad:ci:app:start'].invoke
end end
end end

View file

@ -5,7 +5,7 @@ namespace :zammad do
namespace :test do namespace :test do
desc 'Stop of all Zammad services and cleans up the database(s)' 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['RAILS_ENV'] ||= 'production'
ENV['DISABLE_DATABASE_ENVIRONMENT_CHECK'] = 'true' ENV['DISABLE_DATABASE_ENVIRONMENT_CHECK'] = 'true'
@ -13,7 +13,7 @@ namespace :zammad do
# otherwise it will fallback to default (develop) # otherwise it will fallback to default (develop)
Rails.env = ENV['RAILS_ENV'] 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 Rake::Task['db:drop:all'].invoke
next if !SearchIndexBackend.enabled? next if !SearchIndexBackend.enabled?

227
lib/websocket_server.rb Normal file
View file

@ -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

View file

@ -87,10 +87,12 @@ OptionParser.new do |opts|
@options[:i] = i @options[:i] = i
end end
opts.on('-k', '--private-key [OPT]', '/path/to/server.key for secure connections') do |k| 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 end
opts.on('-c', '--certificate [OPT]', '/path/to/server.crt for secure connections') do |c| 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
end.parse! end.parse!
@ -125,205 +127,4 @@ if ARGV[0] == 'start' && @options[:d]
after_fork(dir) after_fork(dir)
end end
@clients = {} WebsocketServer.run(@options)
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
Capybara.configure do |config|
config.always_include_port = true
config.default_max_wait_time = 16
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -58,6 +58,9 @@ class ChatTest < ActiveSupport::TestCase
# with websockets # with websockets
assert(User.first) assert(User.first)
# make sure to emulate unconnected WS env
ActiveRecord::Base.remove_connection
message = Sessions::Event.run( message = Sessions::Event.run(
event: 'login', event: 'login',
payload: {}, payload: {},