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()