File indexing completed on 2024-07-14 15:56:55

0001 #!/usr/bin/env ruby
0002 # frozen_string_literal: true
0003 
0004 # SPDX-License-Identifier: AGPL-3.0-or-later
0005 # SPDX-FileCopyrightText: 2022-2023 Harald Sitter <sitter@kde.org>
0006 
0007 require 'fileutils'
0008 require 'logger'
0009 require 'shellwords'
0010 require 'tmpdir'
0011 
0012 def at_bus_exists?
0013   IO.popen(['dbus-send', '--print-reply=literal', '--dest=org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus.ListNames'], 'r') do |io|
0014     io.read.include?('org.a11y.Bus')
0015   end
0016 end
0017 
0018 def at_bus_address
0019   IO.popen(['dbus-send', '--print-reply=literal', '--dest=org.a11y.Bus', '/org/a11y/bus', 'org.a11y.Bus.GetAddress'], 'r') do |io|
0020     io.read.strip
0021   end
0022 end
0023 
0024 def terminate_pgids(pgids)
0025   (pgids || []).reverse.each do |pgid|
0026     Process.kill('-TERM', pgid)
0027     Process.waitpid(pgid)
0028   rescue Errno::ECHILD => e
0029     warn "Process group not found #{e}"
0030   rescue Errno::ESRCH => e
0031     warn "Process not found #{e}"
0032   end
0033 end
0034 
0035 def terminate_pids(pids)
0036   (pids || []).reverse.each do |pid|
0037     Process.kill('TERM', pid)
0038     Process.waitpid(pid)
0039   end
0040 end
0041 
0042 class ATSPIBus
0043   def initialize(logger:)
0044     @logger = logger
0045   end
0046 
0047   def with(&block)
0048     return block.yield if at_bus_exists?
0049 
0050     bus_existed = at_bus_exists?
0051 
0052     launcher_path = find_program('at-spi-bus-launcher')
0053     registry_path = find_program('at-spi2-registryd')
0054     @logger.warn "Testing with #{launcher_path} and #{registry_path}"
0055 
0056     pids = []
0057     pids << spawn(launcher_path, '--launch-immediately')
0058     pids << spawn(registry_path)
0059     block.yield
0060   ensure
0061     # NB: do not signal KILL the launcher, it only shutsdown the a11y dbus-daemon when terminated!
0062     terminate_pids(pids)
0063     # Restart the regular bus or the user may be left with malfunctioning accerciser
0064     # (intentionally ignoring the return value! it never passes in the CI & freebsd in absence of systemd)
0065     system('systemctl', 'restart', '--user', 'at-spi-dbus-bus.service') if !pids&.empty? && bus_existed
0066   end
0067 
0068   private
0069 
0070   def find_program(name)
0071     @atspi_paths ||= [
0072       '/usr/lib/at-spi2-core/', # debians
0073       '/usr/libexec/', # newer debians
0074       '/usr/lib/at-spi2/', # suses
0075       '/usr/libexec/at-spi2/', # newer suses
0076       '/usr/lib/' # arch
0077     ]
0078 
0079     @atspi_paths.each do |x|
0080       path = "#{x}/#{name}"
0081       return path if File.exist?(path)
0082     end
0083     raise "Could not resolve absolute path for #{name}; searched in #{@atspi_paths.join(', ')}"
0084   end
0085 end
0086 
0087 def kwin_reexec!
0088   # KWin redirection is a bit tricky. We want to run this script itself under kwin so both the flask server and
0089   # the actual test script can inherit environment variables from that nested kwin. Most notably this is required
0090   # to have the correct DISPLAY set to access the xwayland instance.
0091   # As such this function has two behavior modes. If kwin redirection should run (that is: it's not yet inside kwin)
0092   # it will fork and exec into kwin. If redirection is not required it yields out.
0093 
0094   return if ENV.include?('KWIN_PID') # already inside a kwin parent
0095   return if ENV['TEST_WITH_KWIN_WAYLAND'] == '0'
0096 
0097   kwin_pid = fork do |pid|
0098     ENV['QT_QPA_PLATFORM'] = 'wayland'
0099     ENV['KWIN_SCREENSHOT_NO_PERMISSION_CHECKS'] = '1'
0100     ENV['KWIN_WAYLAND_NO_PERMISSION_CHECKS'] = '1'
0101     ENV['KWIN_PID'] = pid.to_s
0102     ENV['KWIN_XKB_DEFAULT_KEYMAP'] = 'true'
0103     # Don't set RULES or MODEL, they ought to be valid and we probably don't need to change them from whatever is set!
0104     ENV['XKB_DEFAULT_LAYOUT'] = 'us'
0105     ENV['XKB_DEFAULT_VARIANT'] = ''
0106     ENV['XKB_DEFAULT_OPTIONS'] = ''
0107     extra_args = []
0108     extra_args << '--virtual' if ENV['LIBGL_ALWAYS_SOFTWARE']
0109     extra_args << '--xwayland' if ENV.fetch('TEST_WITH_XWAYLAND', '0').to_i.positive?
0110     extra_args << '--no-global-shortcuts' if ENV.fetch('TEST_WITHOUT_GLOBAL_SHORTCUTS', '1').to_i.positive?
0111     # A bit awkward because of how argument parsing works on the kwin side: we must rely on shell word merging for
0112     # the __FILE__ ARGV bit, separate ARGVs to kwin_wayland would be distinct subprocesses to start but we want
0113     # one processes with a bunch of arguments.
0114     exec('kwin_wayland', '--no-lockscreen', *extra_args,
0115          '--exit-with-session', "#{__FILE__} #{ARGV.shelljoin}")
0116   end
0117   _pid, status = Process.waitpid2(kwin_pid)
0118   status.success? ? exit : abort
0119 end
0120 
0121 def dbus_reexec!(logger:)
0122   return if ENV.include?('CUSTOM_BUS') # already inside a nested bus
0123 
0124   if ENV.fetch('USE_CUSTOM_BUS', '0').to_i.zero? && # not explicitly enabled
0125      at_bus_exists? # already have an a11y bus, use it
0126 
0127     logger.info('using existing dbus session')
0128     return
0129   end
0130 
0131   logger.info('starting dbus session')
0132   ENV['CUSTOM_BUS'] = '1'
0133   # Using spawn() rather than exec() so we can print useful debug information after the run
0134   # (useful to debug problems with shutdown of started processes)
0135   pid = spawn('dbus-run-session', '--', __FILE__, *ARGV, pgroup: true)
0136   pgid = Process.getpgid(pid)
0137   _pid, status = Process.waitpid2(pid)
0138   terminate_pgids([pgid])
0139   logger.info('dbus session ended')
0140   system('ps fja')
0141   status.success? ? exit : abort
0142 end
0143 
0144 # Video recording wrapper
0145 class Recorder
0146   def self.with(&block)
0147     return block.yield unless ENV['RECORD_VIDEO_NAME']
0148 
0149     abort 'RECORD_VIDEO requires that a nested kwin wayland be used! (TEST_WITH_KWIN_WAYLAND)' unless ENV['KWIN_PID']
0150 
0151     # Make sure kwin is up. This can be removed once the code was changed to re-exec as part of a kwin
0152     # subprocess, then the wayland server is ready by the time we get re-executed.
0153     sleep(5)
0154     FileUtils.rm_f(ENV['RECORD_VIDEO_NAME'])
0155     pids = []
0156     pids << spawn('pipewire')
0157     pids << spawn('wireplumber')
0158     pids << spawn(find_program('xdg-desktop-portal-kde'))
0159     pids << spawn('selenium-webdriver-at-spi-recorder', '--output', ENV.fetch('RECORD_VIDEO_NAME'))
0160     5.times do
0161       break if File.exist?(ENV['RECORD_VIDEO_NAME'])
0162 
0163       sleep(1)
0164     end
0165     block.yield
0166   ensure
0167     terminate_pids(pids)
0168     if ENV['RECORD_VIDEO_NAME'] &&
0169        (!File.exist?(ENV['RECORD_VIDEO_NAME']) || File.size(ENV['RECORD_VIDEO_NAME']) < 256_000)
0170       warn "recording apparently didn't work properly"
0171     end
0172   end
0173 
0174   def self.find_program(name)
0175     @paths ||= ENV.fetch('LD_LIBRARY_PATH', '').split(':').map { |x| "#{x}/libexec" } +
0176                [
0177                  '/usr/lib/*/libexec/', # debian
0178                  '/usr/libexec/', # suse
0179                  '/usr/lib/' # arch
0180                ]
0181 
0182     @paths.each do |x|
0183       path = "#{x}/#{name}"
0184       return path if Dir.glob(path)&.first
0185     end
0186     raise "Could not resolve absolute path for #{name}; searched in #{@paths.join(', ')}"
0187   end
0188 end
0189 
0190 class Driver
0191   def self.with(datadir, &block)
0192     pids = []
0193     env = { 'FLASK_ENV' => 'production', 'FLASK_APP' => 'selenium-webdriver-at-spi.py' }
0194     env['GDK_BACKEND'] = 'wayland' if ENV['KWIN_PID']
0195     pids << spawn(env,
0196                   'flask', 'run', '--port', PORT, '--no-reload',
0197                   chdir: datadir)
0198     block.yield
0199   ensure
0200     terminate_pids(pids)
0201   end
0202 end
0203 
0204 PORT = '4723'
0205 $stdout.sync = true # force immediate flushing without internal caching
0206 logger = Logger.new($stdout)
0207 
0208 # Tweak the CIs logging rules. They are way too verbose for our purposes
0209 if ENV['KDECI_BUILD'] == 'TRUE'
0210   ENV['QT_LOGGING_RULES'] = <<-RULES.gsub(/\s/, '')
0211     default=true;*.debug=true;kf.globalaccel.kglobalacceld=false;kf.wayland.client=false;
0212     qt.scenegraph.*=false;qt.qml.diskcache=false;
0213     qt.qml.*=false;qt.qpa.wayland.*=false;qt.quick.dirty=false;qt.accessibility.cache=false;qt.v4.asm=false;
0214     qt.opengl.diskcache=false;qt.qpa.fonts=false;kf.kio.workers.http=false;
0215     qt.quick.*=false;qt.text.*=false;qt.qpa.input.methods=false;
0216     qt.qpa.backingstore=false;qt.gui.*=false;qt.core.plugin.loader=false;
0217   RULES
0218   ENV['QT_LOGGING_RULES'] = ENV['QT_LOGGING_RULES_OVERRIDE'] if ENV.include?('QT_LOGGING_RULES_OVERRIDE')
0219 end
0220 
0221 logger.info 'Installing dependencies'
0222 datadir = File.absolute_path("#{__dir__}/../share/selenium-webdriver-at-spi/")
0223 requirements_installed_marker = "#{Dir.tmpdir}/selenium-requirements-installed"
0224 if !File.exist?(requirements_installed_marker) && File.exist?("#{datadir}/requirements.txt")
0225   raise 'pip3 not found in PATH!' unless system('which', 'pip3')
0226   unless system('pip3', 'install', '-r', 'requirements.txt', chdir: datadir)
0227     unless system('pip3', 'install', '--break-system-packages', '-r', 'requirements.txt', chdir: datadir)
0228       raise 'Failed to run pip3 install!'
0229     end
0230   end
0231 
0232   if ENV['KDECI_BUILD'] == 'TRUE'
0233     File.open(requirements_installed_marker, "w") do |file|
0234       # create an empty file so tests in the same CI container can skip the process
0235     end
0236   end
0237 end
0238 
0239 ENV['PATH'] = "#{Dir.home}/.local/bin:#{ENV.fetch('PATH')}"
0240 
0241 ret = false
0242 
0243 # create a throw-away XDG home, so the test starts with a clean slate
0244 # with every run, and doesn't mess with your local installation
0245 Dir.mktmpdir('selenium') do |xdg_home|
0246   %w[CACHE CONFIG DATA STATE].each do |d|
0247     Dir.mkdir("#{xdg_home}/#{d}")
0248     ENV["XDG_#{d}_HOME"] = "#{xdg_home}/#{d}"
0249   end
0250 
0251   dbus_reexec!(logger: logger)
0252   kwin_reexec!
0253   if ENV['KDECI_BUILD'] == 'TRUE'
0254     raise 'Failed to set dbus env' unless system('dbus-update-activation-environment', '--all')
0255   end
0256 
0257   ATSPIBus.new(logger: logger).with do
0258     # Prevent a race condition in Qt when it tries to figure out the bus address,
0259     # instead just tell it the address explicitly.
0260     # https://codereview.qt-project.org/c/qt/qtbase/+/493700/2
0261     ENV['AT_SPI_BUS_ADDRESS'] = at_bus_address
0262     Recorder.with do
0263       Driver.with(datadir) do
0264         i = 0
0265         begin
0266           require 'net/http'
0267           Net::HTTP.get(URI("http://localhost:#{PORT}/status"))
0268         rescue => e
0269           i += 1
0270           if i < 30
0271             logger.info 'not up yet'
0272             sleep 0.5
0273             retry
0274           end
0275           raise e
0276         end
0277 
0278         logger.info "starting test #{ARGV}"
0279         IO.popen(ARGV, 'r', &:readlines)
0280         ret = $?.success?
0281         logger.info 'tests done'
0282       end
0283     end
0284   end
0285 end
0286 
0287 logger.info "run.rb exiting #{ret}"
0288 ret ? exit : abort