File indexing completed on 2024-05-12 05:36:58

0001 #!/usr/bin/env python3
0002 
0003 # SPDX-FileCopyrightText: 2022 Harald Sitter <sitter@kde.org>
0004 # SPDX-FileCopyrightText: 2023 Fushan Wen <qydwhotmail@gmail.com>
0005 # SPDX-License-Identifier: MIT
0006 
0007 import os
0008 import subprocess
0009 import sys
0010 import time
0011 import unittest
0012 from typing import Final
0013 
0014 from appium import webdriver
0015 from appium.options.common.base import AppiumOptions
0016 from appium.webdriver.common.appiumby import AppiumBy
0017 from gi.repository import Gio, GLib
0018 from selenium.webdriver.support.ui import WebDriverWait
0019 
0020 from utils.GLibMainLoopThread import GLibMainLoopThread
0021 from utils.OrgFreedesktopUPower import OrgFreedesktopUPower
0022 
0023 assert "ENABLE_DISPLAY_DEVICE" in os.environ, "Missing ENABLE_DISPLAY_DEVICE"
0024 
0025 WIDGET_ID: Final = "org.kde.plasma.battery"
0026 POWERDEVIL_PATH: Final = os.environ.get("POWERDEVIL_PATH", "~/kde/usr/lib64/libexec/org_kde_powerdevil")
0027 POWERDEVIL_SERVICE_NAME: Final = "org.kde.Solid.PowerManagement"
0028 ENABLE_DISPLAY_DEVICE: Final = int(os.environ["ENABLE_DISPLAY_DEVICE"]) != 0
0029 
0030 
0031 def name_has_owner(session_bus: Gio.DBusConnection, name: str) -> bool:
0032     """
0033     Whether the given name is available on session bus
0034     """
0035     message: Gio.DBusMessage = Gio.DBusMessage.new_method_call("org.freedesktop.DBus", "/", "org.freedesktop.DBus", "NameHasOwner")
0036     message.set_body(GLib.Variant("(s)", [name]))
0037     reply, _ = session_bus.send_message_with_reply_sync(message, Gio.DBusSendMessageFlags.NONE, 1000)
0038     return reply and reply.get_signature() == 'b' and reply.get_body().get_child_value(0).get_boolean()
0039 
0040 
0041 class BatteryMonitorTests(unittest.TestCase):
0042     """
0043     Tests for the system tray widget
0044     """
0045 
0046     dbus_daemon_pid: str
0047     driver: webdriver.Remote
0048     loop_thread: GLibMainLoopThread
0049     upower_interface: OrgFreedesktopUPower
0050     powerdevil: subprocess.Popen[bytes]
0051 
0052     @classmethod
0053     def setUpClass(cls) -> None:
0054         """
0055         Opens the widget and initialize the webdriver
0056         """
0057         cls.addClassCleanup(lambda: subprocess.Popen(["kill", "-15", cls.dbus_daemon_pid]).wait())
0058         lines: list[str] = subprocess.check_output(['dbus-daemon', '--fork', '--print-address=1', '--print-pid=1', '--session'], universal_newlines=True).strip().splitlines()
0059         assert len(lines) == 2, "Expected exactly 2 lines of output from dbus-daemon"
0060         cls.dbus_daemon_pid = lines[1]
0061         assert int(cls.dbus_daemon_pid) > 0, "Failed to start dbus-daemon"
0062         os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = lines[0]
0063         os.environ["DBUS_SESSION_BUS_ADDRESS"] = lines[0]
0064 
0065         # Start the mocked upower backend
0066         cls.loop_thread = GLibMainLoopThread()
0067         cls.loop_thread.start()
0068         cls.upower_interface = OrgFreedesktopUPower(None, ENABLE_DISPLAY_DEVICE)
0069         # Wait until the mocked upower interface is online
0070         assert cls.upower_interface.registered_event.wait(10), "upower interface is not ready"
0071 
0072         # Start PowerDevil which is used by the dataengine
0073         debug_env: dict[str, str] = os.environ.copy()
0074         debug_env["QT_LOGGING_RULES"] = "org.kde.powerdevil.debug=true"
0075         session_bus: Gio.DBusConnection = Gio.bus_get_sync(Gio.BusType.SESSION)
0076         assert not name_has_owner(session_bus, POWERDEVIL_SERVICE_NAME), "PowerDevil is already running"
0077         cls.powerdevil = subprocess.Popen([POWERDEVIL_PATH], env=debug_env, stdout=sys.stdout, stderr=subprocess.PIPE)
0078         powerdevil_started: bool = False
0079         for _ in range(10):
0080             if name_has_owner(session_bus, POWERDEVIL_SERVICE_NAME):
0081                 powerdevil_started = True
0082                 break
0083             print("waiting for PowerDevil to appear on the dbus session")
0084             time.sleep(1)
0085         if not powerdevil_started:
0086             if "KDECI_BUILD" in os.environ and cls.powerdevil.stderr.readable():
0087                 for line in cls.powerdevil.stderr.readlines():
0088                     if "PRIVATE_API" in line.decode(encoding="utf-8"):
0089                         sys.exit(0)
0090             assert False, "PowerDevil is not running"
0091 
0092         # Now start the appium test
0093         options = AppiumOptions()
0094         options.set_capability("app", f"plasmawindowed -p org.kde.plasma.nano {WIDGET_ID}")
0095         options.set_capability("environ", {
0096             "DBUS_SYSTEM_BUS_ADDRESS": os.environ["DBUS_SYSTEM_BUS_ADDRESS"],
0097             "DBUS_SESSION_BUS_ADDRESS": os.environ["DBUS_SESSION_BUS_ADDRESS"],
0098             "QT_FATAL_WARNINGS": "1",
0099             "QT_LOGGING_RULES": "qt.accessibility.atspi.warning=false;qt.dbus.integration.warning=false;kf.plasma.core.warning=false;kf.windowsystem.warning=false;kf.kirigami.platform.warning=false",
0100         })
0101         cls.driver = webdriver.Remote(command_executor='http://127.0.0.1:4723', options=options)
0102 
0103     def setUp(self) -> None:
0104         pass
0105 
0106     def tearDown(self) -> None:
0107         """
0108         Take screenshot when the current test fails
0109         """
0110         if not self._outcome.result.wasSuccessful():
0111             self.driver.get_screenshot_as_file(f"failed_test_shot_batterymonitor_#{self.id()}.png")
0112 
0113     @classmethod
0114     def tearDownClass(cls) -> None:
0115         """
0116         Make sure to terminate the driver again, lest it dangles.
0117         """
0118         cls.driver.quit()
0119         cls.powerdevil.terminate()
0120         cls.upower_interface.quit()
0121         cls.loop_thread.quit()
0122 
0123     def test_01_batteries_are_listed(self) -> None:
0124         """
0125         Tests the widget can list all available batteries
0126         """
0127         # Don't show battery name for primary power supply batteries. They usually have cryptic serial number names.
0128         self.driver.find_element(by=AppiumBy.NAME, value="Battery")
0129         self.driver.find_element(by=AppiumBy.NAME, value="50%")
0130         self.driver.find_element(by=AppiumBy.NAME, value="Charging")
0131         self.driver.find_element(by=AppiumBy.NAME, value="1:00")  # Remaining time (h) = (40 * 0.5) / 20 = 1
0132 
0133         # The second battery
0134         self.driver.find_element(by=AppiumBy.NAME, value="Battery 2")
0135         self.driver.find_element(by=AppiumBy.NAME, value="100%")
0136         self.driver.find_element(by=AppiumBy.NAME, value="Fully Charged")
0137 
0138         # Wireless Mouse
0139         self.driver.find_element(by=AppiumBy.NAME, value="KDE Gaming Mouse")
0140 
0141     def test_10_ac_line_unplugged(self) -> None:
0142         """
0143         Tests the battery state changes from Charging to Discharging
0144         """
0145         self.upower_interface.set_ac_unplugged()
0146         self.driver.find_element(by=AppiumBy.NAME, value="Battery")
0147         self.driver.find_element(by=AppiumBy.NAME, value="80%")
0148         self.driver.find_element(by=AppiumBy.NAME, value="3:36")  # Remaining time (h) = (40 * 0.8 + 40) / 20 = 3.6
0149         self.driver.find_element(by=AppiumBy.NAME, value="Discharging")
0150 
0151         # The second battery is untouched
0152         self.driver.find_element(by=AppiumBy.NAME, value="Battery 2")
0153         self.driver.find_element(by=AppiumBy.NAME, value="100%")
0154         self.driver.find_element(by=AppiumBy.NAME, value="Fully Charged")
0155 
0156     def test_11_discharging_rate(self) -> None:
0157         """
0158         The remaining time should be updated accordingly when the discharging rate changes
0159         """
0160         self.driver.find_element(by=AppiumBy.NAME, value="3:36")
0161         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY0_OBJECT_PATH, 40.0)
0162         # double weight = 0.005 * std::min<qulonglong>(60, timestamp - m_lastRateTimestamp) = 0.3;
0163         # double current = last * (1 - weight) + update * weight = 20 * 0.7 + 40 * 0.3 = 26;
0164         # Remaining time (h) = (40 * 0.8 + 40) / 26 = 2.77
0165         self.driver.find_element(by=AppiumBy.NAME, value="2:46")
0166 
0167     def test_12_estimating_discharging_rate(self) -> None:
0168         """
0169         When the discharging rate is 0, the remaining time label should show "Estimating…"
0170         """
0171         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY0_OBJECT_PATH, 0)
0172         self.driver.find_element(by=AppiumBy.NAME, value="Estimating…")
0173 
0174     def test_13_hotplug_battery_when_discharging(self) -> None:
0175         """
0176         After the secondary battery is not present, the remaining time should also be updated accordingly.
0177         In upower, "is-present" is not necessarily bound to "power-supply", so this also tests https://invent.kde.org/plasma/powerdevil/-/merge_requests/247
0178         """
0179         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY0_OBJECT_PATH, 20)
0180         # double weight = 0.005 * std::min<qulonglong>(60, timestamp - m_lastRateTimestamp) = 0.3;
0181         # double current = last * (1 - weight) + update * weight = 26 * 0.7 + 20 * 0.3 = 24.2;
0182         # Remaining time (h) = (40 * 0.8 + 40) / 24.2 = 2.98
0183         self.driver.find_element(by=AppiumBy.NAME, value="2:59")
0184         # Unplug
0185         self.upower_interface.set_device_property(OrgFreedesktopUPower.BATTERY1_OBJECT_PATH, "IsPresent", GLib.Variant("b", False))
0186         self.driver.find_element(by=AppiumBy.NAME, value="1:19")  # Remaining time (h) = (40 * 0.8) / 24.2 = 1.32
0187         state_element = self.driver.find_element(by=AppiumBy.NAME, value="Not present")
0188         # Plug in
0189         self.upower_interface.set_device_property(OrgFreedesktopUPower.BATTERY1_OBJECT_PATH, "IsPresent", GLib.Variant("b", True))
0190         self.driver.find_element(by=AppiumBy.NAME, value="2:59")
0191         new_state_element = self.driver.find_element(by=AppiumBy.NAME, value="Fully Charged")
0192         self.assertTrue(new_state_element == state_element)
0193 
0194     def test_20_ac_line_plugged_in(self) -> None:
0195         """
0196         Tests the battery state changes from Discharging to Charging
0197         """
0198         self.upower_interface.set_ac_plugged()
0199         self.driver.find_element(by=AppiumBy.NAME, value="Battery")
0200         self.driver.find_element(by=AppiumBy.NAME, value="90%")
0201         self.driver.find_element(by=AppiumBy.NAME, value="Charging")
0202         self.driver.find_element(by=AppiumBy.NAME, value="0:12")  # Remaining time (h) = 40 * 0.1 / 20
0203 
0204         # The second battery is untouched
0205         self.driver.find_element(by=AppiumBy.NAME, value="Battery 2")
0206         self.driver.find_element(by=AppiumBy.NAME, value="100%")
0207         self.driver.find_element(by=AppiumBy.NAME, value="Fully Charged")
0208 
0209     def test_21_charging_rate(self) -> None:
0210         """
0211         The remaining time should be updated accordingly when the charging rate changes
0212         """
0213         self.driver.find_element(by=AppiumBy.NAME, value="0:12")
0214         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY0_OBJECT_PATH, -10.0)
0215         # Remaining time (h) = 40 * 0.1 / 10
0216         self.driver.find_element(by=AppiumBy.NAME, value="0:24")
0217 
0218     def test_22_estimating_charging_rate(self) -> None:
0219         """
0220         When the charging rate is 0, the remaining time label should be hidden
0221         """
0222         time_element = self.driver.find_element(by=AppiumBy.NAME, value="0:24")
0223         self.assertTrue(time_element.is_displayed())
0224         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY0_OBJECT_PATH, 0.0)
0225         WebDriverWait(self.driver, 5).until(lambda _: not time_element.is_displayed())
0226 
0227     def test_23_hotplug_battery_when_charging(self) -> None:
0228         """
0229         After the secondary battery is not present, the remaining time should also be updated accordingly.
0230         In upower, "is-present" is not necessarily bound to "power-supply", so this also tests https://invent.kde.org/plasma/powerdevil/-/merge_requests/247
0231         """
0232         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY0_OBJECT_PATH, -20.0)
0233         self.upower_interface.set_energy_props(OrgFreedesktopUPower.BATTERY1_OBJECT_PATH, 0, 80.0)
0234         self.driver.find_element(by=AppiumBy.NAME, value="0:36")  # Remaining time (h) = (40 * 0.1 + 40 * 0.2) / 20
0235         # Unplug
0236         self.upower_interface.set_device_property(OrgFreedesktopUPower.BATTERY1_OBJECT_PATH, "IsPresent", GLib.Variant("b", False))
0237         self.driver.find_element(by=AppiumBy.NAME, value="0:12")  # Remaining time (h) = (40 * 0.1) / 20
0238         self.driver.find_element(by=AppiumBy.NAME, value="Not present")
0239         # Plug in
0240         self.upower_interface.set_device_property(OrgFreedesktopUPower.BATTERY1_OBJECT_PATH, "IsPresent", GLib.Variant("b", True))
0241         self.driver.find_element(by=AppiumBy.NAME, value="0:36")  # Remaining time (h) = (40 * 0.1 + 40 * 0.2) / 20
0242         self.driver.find_element(by=AppiumBy.NAME, value="Charging")
0243 
0244     def test_30_device_added(self) -> None:
0245         """
0246         Add a external device like a wireless keyboard
0247         """
0248         properties = {
0249             "NativePath": GLib.Variant("s", "hidpp_battery_1"),
0250             "Vendor": GLib.Variant("s", "KDE"),
0251             "Model": GLib.Variant("s", "Wireless Keyboard"),
0252             "Serial": GLib.Variant("s", "1234-5678-91"),
0253             "UpdateTime": GLib.Variant('t', int(time.time())),
0254             "Type": GLib.Variant("u", 6),  # Keyboard
0255             "PowerSupply": GLib.Variant("b", False),
0256             "HasHistory": GLib.Variant("b", False),
0257             "HasStatistics": GLib.Variant("b", False),
0258             "Online": GLib.Variant("b", False),  # only valid for AC
0259             "Energy": GLib.Variant("d", 0.0),  # only valid for batteries
0260             "EnergyEmpty": GLib.Variant("d", 0.0),  # only valid for batteries
0261             "EnergyFull": GLib.Variant("d", 0.0),  # only valid for batteries
0262             "EnergyFullDesign": GLib.Variant("d", 0.0),  # only valid for batteries
0263             "EnergyRate": GLib.Variant("d", 0.0),  # only valid for batteries
0264             "Voltage": GLib.Variant("d", 0.0),
0265             "ChargeCycles": GLib.Variant('i', -1),  # only valid for batteries
0266             "Luminosity": GLib.Variant("d", 0.0),
0267             "TimeToEmpty": GLib.Variant("x", 0),  # only valid for batteries
0268             "TimeToFull": GLib.Variant("x", 0),  # only valid for batteries
0269             "Percentage": GLib.Variant("d", 100.0),  # only valid for batteries
0270             "Temperature": GLib.Variant("d", 0.0),  # only valid for batteries
0271             "IsPresent": GLib.Variant("b", False),  # only valid for batteries
0272             "State": GLib.Variant("u", 0),  # Unknown, only valid for batteries
0273             "IsRechargeable": GLib.Variant("b", False),  # only valid for batteries
0274             "Capacity": GLib.Variant("d", 0.0),  # only valid for batteries
0275             "Technology": GLib.Variant("u", 0),  # Unknown, only valid for batteries
0276             "WarningLevel": GLib.Variant("u", 1),  # None
0277             "BatteryLevel": GLib.Variant("u", 3),  # Low
0278             "IconName": GLib.Variant("s", ""),
0279         }
0280         self.upower_interface.add_device(OrgFreedesktopUPower.WIRELESS_KEYBOARD_OBJECT_PATH, properties)
0281         # Wireless Keyboard
0282         self.driver.find_element(by=AppiumBy.NAME, value="KDE Wireless Keyboard")
0283 
0284     def test_31_device_removed(self) -> None:
0285         """
0286         Unplug a external device like a wireless keyboard
0287         """
0288         keyboard_element = self.driver.find_element(by=AppiumBy.NAME, value="KDE Wireless Keyboard")
0289         self.assertTrue(keyboard_element.is_displayed())
0290         self.upower_interface.remove_device(OrgFreedesktopUPower.WIRELESS_KEYBOARD_OBJECT_PATH)
0291         WebDriverWait(self.driver, 5).until(lambda _: not keyboard_element.is_displayed())
0292 
0293     def test_32_device_added_removed_race_condition(self) -> None:
0294         """
0295         Detects if there is any potential race condition when a device is plugged in and unplugged in a flash
0296         """
0297         for i in range(50):
0298             print(f"i={str(i)}", file=sys.stderr, flush=True)
0299             self.test_30_device_added()
0300             self.test_31_device_removed()
0301 
0302         self.driver.find_element(by=AppiumBy.NAME, value="KDE Gaming Mouse")
0303 
0304 
0305 if __name__ == '__main__':
0306     assert os.path.exists(POWERDEVIL_PATH), f"{POWERDEVIL_PATH} does not exist"
0307     unittest.main()