From 66888ad6368cdb6761fd5021f6bd2d8101200080 Mon Sep 17 00:00:00 2001 From: Sam Thursfield Date: Fri, 22 May 2020 22:07:54 +0200 Subject: [PATCH 1/2] libtracker-miner: Report status as 'Paused' when miner is paused This is useful for testing, but also makes sense for the CLI. --- src/libtracker-miner/tracker-miner-object.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libtracker-miner/tracker-miner-object.c b/src/libtracker-miner/tracker-miner-object.c index 733b54f2a..bd99273e5 100644 --- a/src/libtracker-miner/tracker-miner-object.c +++ b/src/libtracker-miner/tracker-miner-object.c @@ -468,7 +468,11 @@ miner_get_property (GObject *object, switch (prop_id) { case PROP_STATUS: - g_value_set_string (value, miner->priv->status); + if (tracker_miner_is_paused (miner)) { + g_value_set_string (value, "Paused"); + } else { + g_value_set_string (value, miner->priv->status); + } break; case PROP_PROGRESS: g_value_set_double (value, miner->priv->progress); -- GitLab From 2e5231317b8b3dab53da7c878f75d5fa8205894b Mon Sep 17 00:00:00 2001 From: Sam Thursfield Date: Mon, 16 Aug 2021 14:03:17 +0200 Subject: [PATCH 2/2] functional-tests: Add miner-power test This test uses [umockdev] to verify that the miner-fs pauses when upower reports a low battery condition. The umockdev dependency is optional and the test will report as being skipped if umockdev isn't available. Depends on https://gitlab.gnome.org/GNOME/tracker-oci-images/-/merge_requests/23 and https://gitlab.gnome.org/GNOME/tracker/-/merge_requests/254 [umockdev]: https://github.com/martinpitt/umockdev --- meson.build | 3 + run-uninstalled.in | 2 +- tests/common/skip-test.sh | 3 + tests/functional-tests/configuration.json.in | 3 +- tests/functional-tests/configuration.py | 3 +- tests/functional-tests/devices.py | 92 +++++++++++ tests/functional-tests/fixtures.py | 3 +- tests/functional-tests/meson.build | 30 +++- tests/functional-tests/miner-power.py | 89 +++++++++++ tests/functional-tests/minerhelper.py | 146 ++++++++++-------- tests/meson.build | 6 +- ...t-bus.conf.in => test-session-bus.conf.in} | 0 tests/test-system-bus.conf | 20 +++ 13 files changed, 324 insertions(+), 76 deletions(-) create mode 100755 tests/common/skip-test.sh create mode 100644 tests/functional-tests/devices.py create mode 100755 tests/functional-tests/miner-power.py rename tests/{test-bus.conf.in => test-session-bus.conf.in} (100%) create mode 100644 tests/test-system-bus.conf diff --git a/meson.build b/meson.build index 8a5bbde65..0869206c5 100644 --- a/meson.build +++ b/meson.build @@ -89,6 +89,9 @@ libmath = cc.find_library('m', required: false) network_manager = dependency('libnm', required: get_option('network_manager')) have_network_manager = network_manager.found() +# We use this in the functional-tests if available. +umockdev = dependency('umockdev-1.0', required: false) + have_tracker_extract = get_option('extract') have_tracker_miner_fs = get_option('miner_fs') have_tracker_miner_rss = get_option('miner_rss') diff --git a/run-uninstalled.in b/run-uninstalled.in index 8d2bcf51f..51e5e0217 100755 --- a/run-uninstalled.in +++ b/run-uninstalled.in @@ -30,7 +30,7 @@ tracker_sparql_dir = "@tracker_sparql_uninstalled_dir@" testutils_dir = "@tracker_uninstalled_testutils_dir@" build_directory = pathlib.Path(__file__).parent -dbus_config = build_directory.joinpath('tests/test-bus.conf') +dbus_config = build_directory.joinpath('tests/test-session-bus.conf') env = os.environ diff --git a/tests/common/skip-test.sh b/tests/common/skip-test.sh new file mode 100755 index 000000000..fe5eba0d7 --- /dev/null +++ b/tests/common/skip-test.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Magic 'test skipped' code, from https://mesonbuild.com/Reference-manual.html#test +exit 77 diff --git a/tests/functional-tests/configuration.json.in b/tests/functional-tests/configuration.json.in index 6a45d8eca..ce2d953a2 100644 --- a/tests/functional-tests/configuration.json.in +++ b/tests/functional-tests/configuration.json.in @@ -1,13 +1,14 @@ { "TEST_CLI_DIR": "@TEST_CLI_DIR@", "TEST_CLI_SUBCOMMANDS_DIR": "@TEST_CLI_SUBCOMMANDS_DIR@", - "TEST_DBUS_DAEMON_CONFIG_FILE": "@TEST_DBUS_DAEMON_CONFIG_FILE@", "TEST_DCONF_PROFILE": "@TEST_DCONF_PROFILE@", "TEST_DOMAIN_ONTOLOGY_RULE": "@TEST_DOMAIN_ONTOLOGY_RULE@", "TEST_EXTRACTOR_RULES_DIR": "@TEST_EXTRACTOR_RULES_DIR@", "TEST_EXTRACTORS_DIR": "@TEST_EXTRACTORS_DIR@", "TEST_GSETTINGS_SCHEMA_DIR": "@TEST_GSETTINGS_SCHEMA_DIR@", "TEST_LANGUAGE_STOP_WORDS_DIR": "@TEST_LANGUAGE_STOP_WORDS_DIR@", + "TEST_SESSION_BUS_CONFIG_FILE": "@TEST_SESSION_BUS_CONFIG_FILE@", + "TEST_SYSTEM_BUS_CONFIG_FILE": "@TEST_SYSTEM_BUS_CONFIG_FILE@", "TEST_WRITEBACK_MODULES_DIR": "@TEST_WRITEBACK_MODULES_DIR@", "TEST_TAP_ENABLED": @TEST_TAP_ENABLED@, "TRACKER_EXTRACT_PATH": "@TRACKER_EXTRACT_PATH@" diff --git a/tests/functional-tests/configuration.py b/tests/functional-tests/configuration.py index ec68cc681..545d23169 100644 --- a/tests/functional-tests/configuration.py +++ b/tests/functional-tests/configuration.py @@ -38,7 +38,8 @@ with open(os.environ['TRACKER_FUNCTIONAL_TEST_CONFIG']) as f: config = json.load(f) -TEST_DBUS_DAEMON_CONFIG_FILE = config['TEST_DBUS_DAEMON_CONFIG_FILE'] +TEST_SESSION_BUS_CONFIG_FILE = config['TEST_SESSION_BUS_CONFIG_FILE'] +TEST_SYSTEM_BUS_CONFIG_FILE = config['TEST_SYSTEM_BUS_CONFIG_FILE'] TRACKER_EXTRACT_PATH = config['TRACKER_EXTRACT_PATH'] diff --git a/tests/functional-tests/devices.py b/tests/functional-tests/devices.py new file mode 100644 index 000000000..cab239331 --- /dev/null +++ b/tests/functional-tests/devices.py @@ -0,0 +1,92 @@ +# Copyright (C) 2020, Sam Thursfield +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + +""" +Mock device helpers for testing. +""" + + +import logging +import os + +try: + import gi + gi.require_version('UMockdev', '1.0') + from gi.repository import UMockdev + HAVE_UMOCKDEV = True +except ImportError as e: + HAVE_UMOCKDEV = False + print("Did not find UMockdev library: %s." % e) + +log = logging.getLogger(__name__) + + +class UMockdevNotFound(Exception): + pass + + +class MockBattery(): + def __init__(self, testbed): + self.testbed = testbed + # Mostly copied from + # https://github.com/martinpitt/umockdev/blob/master/docs/examples/battery.py + self.device = testbed.add_device('power_supply', 'fakeBAT0', None, + ['type', 'Battery', + 'present', '1', + 'status', 'Discharging', + 'energy_full', '60000000', + 'energy_full_design', '80000000', + 'energy_now', '48000000', + 'voltage_now', '12000000'], + ['POWER_SUPPLY_ONLINE', '1']) + + def set_battery_power_normal_charge(self): + self.testbed.set_attribute(self.device, 'status', 'Discharging') + self.testbed.set_attribute(self.device, 'energy_now', '48000000') + self.testbed.uevent(self.device, 'change') + + def set_battery_power_low_charge(self): + self.testbed.set_attribute(self.device, 'status', 'Discharging') + self.testbed.set_attribute(self.device, 'energy_now', '1500000') + self.testbed.uevent(self.device, 'change') + + def set_ac_power(self): + self.testbed.set_attribute(self.device, 'status', 'Charging') + self.testbed.uevent(self.device, 'change') + + +def libumockdev_loaded(): + """Returns True if the process was run inside `umockdev-wrapper`.""" + return 'libumockdev-preload' in os.environ['LD_PRELOAD'] + + +def create_testbed(): + if not HAVE_UMOCKDEV: + raise UMockdevNotFound() + return UMockdev.Testbed.new() + + +def upowerd_path(): + with open('/usr/share/dbus-1/system-services/org.freedesktop.UPower.service') as f: + for line in f: + if line.startswith('Exec='): + upowerd_path = line.split('=', 1)[1].strip() + break + else: + sys.stderr.write('Cannot determine upowerd path\n') + sys.exit(1) + return upowerd_path diff --git a/tests/functional-tests/fixtures.py b/tests/functional-tests/fixtures.py index ab4724d1a..d01664226 100644 --- a/tests/functional-tests/fixtures.py +++ b/tests/functional-tests/fixtures.py @@ -108,7 +108,7 @@ class TrackerMinerTest(ut.TestCase): extra_env['LANG'] = 'en_GB.utf8' self.sandbox = trackertestutils.helpers.TrackerDBusSandbox( - session_bus_config_file=cfg.TEST_DBUS_DAEMON_CONFIG_FILE, extra_env=extra_env) + session_bus_config_file=cfg.TEST_SESSION_BUS_CONFIG_FILE, extra_env=extra_env) self.sandbox.start() @@ -123,7 +123,6 @@ class TrackerMinerTest(ut.TestCase): self.miner_fs = MinerFsHelper(self.sandbox.get_session_bus_connection()) self.miner_fs.start() - self.miner_fs.start_watching_progress() self.tracker = trackertestutils.helpers.StoreHelper( self.miner_fs.get_sparql_connection()) diff --git a/tests/functional-tests/meson.build b/tests/functional-tests/meson.build index 9ed3be7b5..141a88161 100644 --- a/tests/functional-tests/meson.build +++ b/tests/functional-tests/meson.build @@ -1,5 +1,11 @@ python = find_program('python3') +if umockdev.found() + umockdev_wrapper = find_program('umockdev-wrapper') +else + umockdev_wrapper = python +endif + # Configure functional tests to run completely from source tree. testconf = configuration_data() @@ -9,7 +15,6 @@ tracker_extractors_dir = meson.current_build_dir() / '..' / '..' / 'src' / 'trac testconf.set('TEST_CLI_DIR', tracker_uninstalled_cli_dir) testconf.set('TEST_CLI_SUBCOMMANDS_DIR', tracker_uninstalled_cli_subcommands_dir) -testconf.set('TEST_DBUS_DAEMON_CONFIG_FILE', build_root / 'tests' / 'test-bus.conf') testconf.set('TEST_DCONF_PROFILE', dconf_profile_full_path) testconf.set('TEST_DOMAIN_ONTOLOGY_RULE', meson.current_build_dir() / 'test-domain.rule') testconf.set('TEST_EXTRACTOR_RULES_DIR', tracker_uninstalled_extract_rules_dir) @@ -17,6 +22,8 @@ testconf.set('TEST_EXTRACTORS_DIR', tracker_extractors_dir) testconf.set('TEST_GSETTINGS_SCHEMA_DIR', tracker_miners_uninstalled_gsettings_schema_dir) testconf.set('TEST_LANGUAGE_STOP_WORDS_DIR', tracker_uninstalled_stop_words_dir) testconf.set('TEST_ONTOLOGIES_DIR', tracker_uninstalled_nepomuk_ontologies_dir) +testconf.set('TEST_SESSION_BUS_CONFIG_FILE', build_root / 'tests' / 'test-session-bus.conf') +testconf.set('TEST_SYSTEM_BUS_CONFIG_FILE', meson.current_source_dir() / '..' / 'test-system-bus.conf') testconf.set('TEST_WRITEBACK_MODULES_DIR', tracker_uninstalled_writeback_modules_dir) testconf.set('TEST_TAP_ENABLED', get_option('tests_tap_protocol').to_string()) testconf.set('TRACKER_EXTRACT_PATH', uninstalled_tracker_extract_path) @@ -154,6 +161,10 @@ else warning('No GStreamer h264 codec was detected. Some extractor tests will be disabled.') endif +umockdev_tests = [ + 'miner-power', +] + test_env = environment() if get_option('tracker_core') == 'subproject' @@ -192,3 +203,20 @@ foreach t: functional_tests suite: ['functional'], timeout: 120) endforeach + +if umockdev.found() + # FIXME: these tests don't appear in the test runner output if umockdev + # wasn't found. We should really report them as skipped, but it's tricky. + foreach t: umockdev_tests + file = meson.current_source_dir() / '@0@.py'.format(t) + test(t, umockdev_wrapper, + args: [python.path(), file], + env: test_env, + suite: ['functional', 'umockdev']) + endforeach +else + foreach t: umockdev_tests + test(t, skip_test, + suite: ['functional', 'umockdev']) + endforeach +endif diff --git a/tests/functional-tests/miner-power.py b/tests/functional-tests/miner-power.py new file mode 100755 index 000000000..181f7cb70 --- /dev/null +++ b/tests/functional-tests/miner-power.py @@ -0,0 +1,89 @@ +# Copyright (C) 2020, Sam Thursfield (sam@afuera.me.uk) +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +""" +Test that the miner responds to changes in power / battery status. +""" + +import os +import sys + +import configuration +import devices +import fixtures +import minerhelper + +import trackertestutils + + +testbed = None + +class MinerPowerTest(fixtures.TrackerMinerTest): + def setUp(self): + # We don't use setUp() from the base class because we need to start + # upowerd before the miner-fs. + + extra_env = configuration.test_environment(self.workdir) + extra_env['LANG'] = 'en_GB.utf8' + + # This sets the UMOCKDEV_DIR variable in the process environment. + #testbed = self.sandbox.get_umockdev_testbed() + self.battery = devices.MockBattery(testbed) + self.battery.set_battery_power_normal_charge() + + self.sandbox = trackertestutils.helpers.TrackerDBusSandbox( + session_bus_config_file=configuration.TEST_SESSION_BUS_CONFIG_FILE, + system_bus_config_file=configuration.TEST_SYSTEM_BUS_CONFIG_FILE, + extra_env=extra_env) + + try: + self.sandbox.start() + self.sandbox.system_bus.activate_service('org.freedesktop.UPower', '/org/freedesktop/UPower') + self.sandbox.set_config(self.config()) + + self.miner_fs = minerhelper.MinerFsHelper(self.sandbox.get_session_bus_connection()) + self.miner_fs.start() + except Exception: + self.sandbox.stop() + raise + + def test_miner_fs_pause_on_low_battery(self): + """The miner-fs should stop indexing if there's a low battery warning.""" + minerhelper.await_status(self.miner_fs.miner_fs, "Idle") + + with minerhelper.await_signal(self.miner_fs.miner_fs, "Paused"): + self.battery.set_battery_power_low_charge() + self.assertEqual(self.miner_fs.get_status(), "Paused") + + with minerhelper.await_signal(self.miner_fs.miner_fs, "Resumed"): + self.battery.set_ac_power() + self.assertEqual(self.miner_fs.get_status(), "Idle") + + +if __name__ == "__main__": + if not devices.HAVE_UMOCKDEV: + # Return 'skipped' error code so `meson test` reports the test + # correctly. + sys.exit(77) + + if not devices.libumockdev_loaded(): + raise RuntimeError("This test must be run inside umockdev-wrapper.") + + # This sets a process-wide environment variable UMOCKDEV_DIR. + testbed = devices.create_testbed() + + fixtures.tracker_test_main() diff --git a/tests/functional-tests/minerhelper.py b/tests/functional-tests/minerhelper.py index 4d2a5c495..718de632d 100644 --- a/tests/functional-tests/minerhelper.py +++ b/tests/functional-tests/minerhelper.py @@ -20,11 +20,13 @@ import gi gi.require_version('Tracker', '3.0') -from gi.repository import Gio, GLib +from gi.repository import Gio, GLib, GObject from gi.repository import Tracker +import contextlib import logging +import trackertestutils.dbusdaemon import trackertestutils.mainloop import configuration @@ -33,11 +35,80 @@ import configuration log = logging.getLogger(__name__) -class WakeupCycleTimeoutException(RuntimeError): +class AwaitTimeoutException(RuntimeError): pass -DEFAULT_TIMEOUT = 10 +def await_status(miner_iface, target_status, timeout=configuration.AWAIT_TIMEOUT): + log.info("Blocking until miner reports status of %s", target_status) + loop = trackertestutils.mainloop.MainLoop() + + if miner_iface.GetStatus() == target_status: + log.info("Status is %s now", target_status) + return + + def signal_cb(proxy, sender_name, signal_name, parameters): + if signal_name == 'Progress': + status, progress, remaining_time = parameters.unpack() + log.debug("Got status: %s", status) + if status == target_status: + loop.quit() + + def timeout_cb(): + log.info("Timeout fired after %s seconds", timeout) + raise AwaitTimeoutException( + f"Timeout awaiting miner status of '{target_status}'") + + signal_id = miner_iface.connect('g-signal', signal_cb) + timeout_id = GLib.timeout_add_seconds(timeout, timeout_cb) + + loop.run_checked() + + GObject.signal_handler_disconnect(miner_iface, signal_id) + GLib.source_remove(timeout_id) + + +class await_signal(): + """Context manager to await a specific D-Bus signal. + + Useful to wait for org.freedesktop.Tracker3.Miner signals like + Paused and Resumed. + + """ + def __init__(self, miner_iface, signal_name, + timeout=configuration.AWAIT_TIMEOUT): + self.miner_iface = miner_iface + self.signal_name = signal_name + self.timeout = timeout + + self.loop = trackertestutils.mainloop.MainLoop() + + def __enter__(self): + log.info("Awaiting signal %s", self.signal_name) + + def signal_cb(proxy, sender_name, signal_name, parameters): + if signal_name == self.signal_name: + log.debug("Received signal %s", signal_name) + self.loop.quit() + + def timeout_cb(): + log.info("Timeout fired after %s seconds", self.timeout) + raise AwaitTimeoutException( + f"Timeout awaiting signal '{self.signal_name}'") + + self.signal_id = self.miner_iface.connect('g-signal', signal_cb) + self.timeout_id = GLib.timeout_add_seconds(self.timeout, timeout_cb) + + def __exit__(self, etype, evalue, etraceback): + if etype is not None: + return False + + self.loop.run_checked() + + GLib.source_remove(self.timeout_id) + GObject.signal_handler_disconnect(self.miner_iface, self.signal_id) + + return True class MinerFsHelper (): @@ -66,78 +137,17 @@ class MinerFsHelper (): def start(self): self.miner_fs.Start() + trackertestutils.dbusdaemon.await_bus_name(self.bus, self.MINERFS_BUSNAME) def stop(self): self.miner_fs.Stop() + def get_status(self): + return self.miner_fs.GetStatus() + def get_sparql_connection(self): return Tracker.SparqlConnection.bus_new( 'org.freedesktop.Tracker3.Miner.Files', None, self.bus) - def start_watching_progress(self): - self._previous_status = None - self._target_wakeup_count = None - self._wakeup_count = 0 - - def signal_handler(proxy, sender_name, signal_name, parameters): - if signal_name == 'Progress': - self._progress_cb(*parameters.unpack()) - - self._progress_handler_id = self.miner_fs.connect('g-signal', signal_handler) - - def stop_watching_progress(self): - if self._progress_handler_id != 0: - self.miner_fs.disconnect(self._progress_handler_id) - - def _progress_cb(self, status, progress, remaining_time): - if self._previous_status is None: - self._previous_status = status - if self._previous_status != 'Idle' and status == 'Idle': - self._wakeup_count += 1 - - if self._target_wakeup_count is not None and self._wakeup_count >= self._target_wakeup_count: - self.loop.quit() - - def wakeup_count(self): - """Return the number of wakeup-to-idle cycles the miner-fs completed.""" - return self._wakeup_count - - def await_wakeup_count(self, target_wakeup_count, timeout=DEFAULT_TIMEOUT): - """Block until the miner has completed N wakeup-and-idle cycles. - - This function is for use by miner-fs tests that should trigger an - operation in the miner, but which do not cause a new resource to be - inserted. These tests can instead wait for the status to change from - Idle to Processing... and then back to Idle. - - The miner may change its status any number of times, but you can use - this function reliably as follows: - - wakeup_count = miner_fs.wakeup_count() - # Trigger a miner-fs operation somehow ... - miner_fs.await_wakeup_count(wakeup_count + 1) - # The miner has probably finished processing the operation now. - - If the timeout is reached before enough wakeup cycles complete, an - exception will be raised. - - """ - - assert self._target_wakeup_count is None - - if self._wakeup_count >= target_wakeup_count: - log.debug("miner-fs wakeup count is at %s (target is %s). No need to wait", self._wakeup_count, target_wakeup_count) - else: - def _timeout_cb(): - raise WakeupCycleTimeoutException() - timeout_id = GLib.timeout_add_seconds(timeout, _timeout_cb) - - log.debug("Waiting for miner-fs wakeup count of %s (currently %s)", target_wakeup_count, self._wakeup_count) - self._target_wakeup_count = target_wakeup_count - self.loop.run_checked() - - self._target_wakeup_count = None - GLib.source_remove(timeout_id) - def index_location(self, uri, graphs=None, flags=None): return self.index.IndexLocation('(sasas)', uri, graphs or [], flags or []) diff --git a/tests/meson.build b/tests/meson.build index b559274ca..ddf8cfa8f 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -1,3 +1,5 @@ +skip_test = find_program('common/skip-test.sh') + subdir('common') subdir('libtracker-miners-common') @@ -10,8 +12,8 @@ endif subdir('services') test_bus_conf_file = configure_file( - input: 'test-bus.conf.in', - output: 'test-bus.conf', + input: 'test-session-bus.conf.in', + output: 'test-session-bus.conf', configuration: conf) if get_option('functional_tests') diff --git a/tests/test-bus.conf.in b/tests/test-session-bus.conf.in similarity index 100% rename from tests/test-bus.conf.in rename to tests/test-session-bus.conf.in diff --git a/tests/test-system-bus.conf b/tests/test-system-bus.conf new file mode 100644 index 000000000..04e797135 --- /dev/null +++ b/tests/test-system-bus.conf @@ -0,0 +1,20 @@ + + + + system + + unix:tmpdir=./ + + + + + + + + + + + + + -- GitLab