diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 320765c..f6ba7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,82 @@ jobs: with: name: couchplay-build path: couchplay-*.tar.gz + + e2e-tests: + runs-on: ubuntu-latest + container: + image: fedora:41 + needs: build + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + dnf install -y --skip-unavailable \ + cmake gcc-c++ git make ruby which \ + qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qt5compat-devel qt6-qtwayland-devel \ + kf6-kirigami-devel kf6-ki18n-devel kf6-kcoreaddons-devel \ + kf6-kconfig-devel kf6-kiconthemes-devel kf6-qqc2-desktop-style \ + kf6-kglobalaccel-devel kf6-kwindowsystem-devel \ + extra-cmake-modules \ + pipewire-devel polkit-devel \ + kwayland-devel kpipewire-devel plasma-wayland-protocols-devel \ + kwin-wayland \ + wayland-devel wayland-protocols-devel \ + at-spi2-core at-spi2-atk-devel \ + python3-pip python3-dbus python3-evdev python3-numpy \ + python3-flask python3-pyatspi python3-gobject-base \ + gobject-introspection-devel dbus-x11 \ + xcb-util-devel + modprobe uinput || true + # selenium-webdriver-at-spi-run requires /usr/bin/pip3 in PATH + # Fedora's python3-pip only provides 'python3 -m pip', not /usr/bin/pip3 + printf '#!/bin/sh\nexec python3 -m pip "$@"\n' > /usr/bin/pip3 + chmod +x /usr/bin/pip3 + + - name: Build selenium-webdriver-at-spi + run: | + git clone --depth 1 https://invent.kde.org/sdk/selenium-webdriver-at-spi.git /tmp/selenium-webdriver-at-spi + # Patch out videorecorder — it needs Qt6GuiPrivate which conflicts with qt6-qtbase-devel on F41 + sed -i '/add_subdirectory(videorecorder)/d' /tmp/selenium-webdriver-at-spi/CMakeLists.txt + cmake -B /tmp/selenium-webdriver-at-spi/build \ + -S /tmp/selenium-webdriver-at-spi \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DQT_MIN_VERSION=6.8 + cmake --build /tmp/selenium-webdriver-at-spi/build --parallel 2 + cmake --install /tmp/selenium-webdriver-at-spi/build + + - name: Install Python dependencies + run: pip install -r appiumtests/requirements.txt + + - name: Configure Git safe directory + run: git config --global --add safe.directory /__w/couchplay/couchplay + + - name: Configure CMake + run: cmake -B build -DBUILD_TESTING=ON + + - name: Build + run: cmake --build build --parallel 2 + + - name: Install desktop file + run: | + ln -sf "$(pwd)/build/bin/couchplay" /usr/local/bin/couchplay + ln -sf "$(pwd)/build/bin/couchplay" /usr/local/bin/io.github.hikaps.couchplay + cp data/io.github.hikaps.couchplay.desktop \ + /usr/share/applications/io.github.hikaps.couchplay.desktop + + - name: Run e2e tests + run: selenium-webdriver-at-spi-run pytest appiumtests/ -v + env: + QT_LINUX_ACCESSIBILITY_ALWAYS_ON: 1 + XDG_RUNTIME_DIR: /tmp/runtime-runner + QT_QPA_PLATFORM: offscreen + TEST_WITH_KWIN_WAYLAND: "0" + + - name: Upload failure screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-failure-screenshots + path: appiumtests/screenshots/ + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index 019d829..da043b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,14 @@ distrobox enter fedora-dev -- cmake --build build # Run (on HOST - gamescope requires host environment) ./build/bin/couchplay -# Tests +# Unit tests distrobox enter fedora-dev -- ctest --test-dir build --output-on-failure -# Single test: ctest --test-dir build -R DeviceManagerTest --output-on-failure +# E2E tests (requires KDE Plasma Wayland session + selenium-webdriver-at-spi) +pip install -r appiumtests/requirements.txt +QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 selenium-webdriver-at-spi-run pytest appiumtests/ -v + +# Single unit test: ctest --test-dir build -R DeviceManagerTest --output-on-failure # List tests: ctest --test-dir build -N # Direct run: ./build/bin/test_devicemanager ``` @@ -32,6 +36,7 @@ distrobox enter fedora-dev -- ctest --test-dir build --output-on-failure ├── src/qml/ # UI layer (pages + components) - SEE ./src/qml/AGENTS.md ├── helper/ # Privileged D-Bus service - SEE ./helper/AGENTS.md ├── tests/ # QtTest unit tests (11 files, 7.2K lines) - SEE ./tests/AGENTS.md +├── appiumtests/ # E2E tests (selenium-webdriver-at-spi) - SEE ./appiumtests/AGENTS.md ├── src/dbus/ # D-Bus client for helper service └── data/ # Icons, polkit policy, D-Bus service files ``` @@ -43,6 +48,7 @@ distrobox enter fedora-dev -- ctest --test-dir build --output-on-failure | Manager architecture | `./src/core/AGENTS.md` | DeviceManager, SessionManager, etc. | | QML layer | `./src/qml/AGENTS.md` | Kirigami components, page patterns | | Test patterns | `./tests/AGENTS.md` | Test naming, fixtures, mocking | +| E2E test patterns | `./appiumtests/AGENTS.md` | selenium-webdriver-at-spi, AT-SPI | | Privileged helper | `./helper/AGENTS.md` | D-Bus service, user mgmt, device ownership | | Device detection | `src/core/DeviceManager.{cpp,h}` | Parses `/proc/bus/input/devices` | | Session orchestration | `src/core/SessionRunner.{cpp,h}` | Starts/stops multiple GamescopeInstance | @@ -99,6 +105,7 @@ distrobox enter fedora-dev -- ctest --test-dir build --output-on-failure - Use `i18nc()` for user-visible strings with context - Component IDs: camelCase (`id: deviceManager`) - Properties: `required property` for mandatory injections +- Accessibility: All interactive elements must have `objectName`, `Accessible.role`, `Accessible.name`, and `Accessible.onPressAction` ### Class Declaration Order 1. Q_OBJECT macro diff --git a/appiumtests/AGENTS.md b/appiumtests/AGENTS.md new file mode 100644 index 0000000..07b59ab --- /dev/null +++ b/appiumtests/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md - E2E Testing Guidelines + +## Overview + +E2E tests use [selenium-webdriver-at-spi](https://invent.kde.org/sdk/selenium-webdriver-at-spi) to drive the CouchPlay UI via the Linux accessibility bus (AT-SPI2). Tests run on a virtual Wayland session managed by the runner. + +## Running Tests + +### Locally (requires KDE Plasma Wayland session) + +```bash +pip install -r appiumtests/requirements.txt +QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 selenium-webdriver-at-spi-run pytest appiumtests/ -v +``` + +### Skip helper-dependent tests (CI mode) + +```bash +selenium-webdriver-at-spi-run pytest appiumtests/ -v -m "not requires_helper" +``` + +### Run a single test file + +```bash +selenium-webdriver-at-spi-run pytest appiumtests/test_home.py -v +``` + +### Run a single test + +```bash +selenium-webdriver-at-spi-run pytest appiumtests/test_home.py::TestHomePage::test_app_launches_home_visible -v +``` + +## Structure + +``` +appiumtests/ +├── conftest.py # Pytest fixtures, driver lifecycle, failure screenshots +├── helpers/ +│ ├── base_test.py # Shared wait/click/navigation utilities +│ ├── mock_helper.py # Mock D-Bus helper service (29 methods) +│ ├── test_users.py # Linux user creation/cleanup for tests +│ └── virtual_devices.py # Virtual gamepad creation via uinput +├── test_home.py # HomePage smoke tests (P0) +├── test_session_setup.py # SessionSetupPage tests (P1) +├── test_session.py # Session lifecycle with mock helper +├── test_profiles.py # Profile management (P2) +├── test_settings.py # Settings tests (P2) +├── test_devices.py # Device assignment (CI-skipped) +├── test_users.py # User management (CI-skipped) +└── requirements.txt +``` + +## Conventions + +### Test Organization + +- Class per page: `Test` +- Method naming: `test__()` +- Priority tiers: P0 (smoke), P1 (core flows), P2 (secondary pages), P3 (helper-dependent) + +### Session Testing + +Session tests (`test_session.py`) use a **mock D-Bus helper** that runs on the system bus. The mock implements all 29 helper methods — `LaunchInstance()` returns a fake PID without spawning gamescope. This enables full session lifecycle testing without real hardware. + +**Session fixtures** (session-scoped, in conftest.py): +- `mock_helper` — starts the mock D-Bus helper Python process +- `test_users` — creates `player2` and `player3` Linux users +- `virtual_gamepads` — creates 2 virtual gamepads via uinput (requires root or uinput module) + +Tests that use these fixtures are automatically opted into session testing. + +### Element Selection + +Priority order: +1. `AppiumBy.ACCESSIBILITY_ID` → maps to `objectName` (most reliable) +2. `AppiumBy.NAME` → maps to `Accessible.name` (localized text) +3. `AppiumBy.CLASS_NAME` → last resort (`[role | name]` format) + +### Markers + +- `@pytest.mark.requires_helper` — tests needing D-Bus helper service (Polkit). Skipped in CI via `-m "not requires_helper"`. + +### Timing + +- Default timeout: 10 seconds (conftest.py `DEFAULT_TIMEOUT`) +- Always use `WebDriverWait` — never `time.sleep()` except in `go_home()` for page transition delays + +### Test Isolation + +- `clean_state` fixture (autouse) navigates to home page before and after each test +- Tests must not depend on state from other tests + +## Adding New Tests + +1. Add `objectName` and `Accessible.*` to the QML element (see root AGENTS.md naming conventions) +2. Create or extend the test file for the target page +3. Inherit from `BaseTest` for shared utilities +4. Use `self.wait_for_element()` / `self.click_by_object_name()` instead of raw driver calls +5. If the test needs the D-Bus helper, add `pytestmark = pytest.mark.requires_helper` at module level + +## CI + +The `e2e-tests` job in `.github/workflows/ci.yml` runs after the `build` job: +- Fedora 41 container with `selenium-webdriver-at-spi`, `kwayland-devel`, `python3-dbus`, `uinput` +- Builds the app, installs the desktop file +- Mock D-Bus helper runs automatically via session fixture +- Test users created automatically via session fixture +- Runs `selenium-webdriver-at-spi-run` which spawns a virtual KWin Wayland session +- Uploads failure screenshots as artifacts diff --git a/appiumtests/__init__.py b/appiumtests/__init__.py new file mode 100644 index 0000000..11ad4f3 --- /dev/null +++ b/appiumtests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors diff --git a/appiumtests/conftest.py b/appiumtests/conftest.py new file mode 100644 index 0000000..9bdcc42 --- /dev/null +++ b/appiumtests/conftest.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import os +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(__file__)) + +import pytest +from appium import webdriver +from appium.options.common.base import AppiumOptions +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +HELPERS_DIR = os.path.join(os.path.dirname(__file__), "helpers") +MOCK_HELPER_SCRIPT = os.path.join(HELPERS_DIR, "mock_helper.py") + + +def pytest_configure(config): + config.addinivalue_line( + "markers", "requires_helper: tests needing D-Bus helper service (skip in CI)" + ) + + +SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "screenshots") +DEFAULT_TIMEOUT = 10 + + +@pytest.fixture(scope="session") +def driver(): + app_id = os.environ.get("COUCHPLAY_APP_ID", "io.github.hikaps.couchplay") + + options = AppiumOptions() + options.load_capabilities( + { + "app": app_id, + "environ": { + "QT_LINUX_ACCESSIBILITY_ALWAYS_ON": "1", + }, + "timeout": 30000, + } + ) + + driver = webdriver.Remote("http://127.0.0.1:4723", options=options) + driver.implicitly_wait(0) + yield driver + driver.quit() + + +@pytest.fixture(scope="session") +def mock_helper(): + proc = subprocess.Popen( + [sys.executable, MOCK_HELPER_SCRIPT], + stderr=subprocess.PIPE, + ) + time.sleep(2) + yield proc + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +@pytest.fixture(scope="session") +def test_users(): + from helpers.test_users import create_test_users, remove_test_users + + create_test_users() + yield + remove_test_users() + + +@pytest.fixture(scope="session") +def virtual_gamepads(): + try: + from helpers.virtual_devices import ( + create_virtual_gamepads, + destroy_virtual_gamepads, + ) + + devices = create_virtual_gamepads(2) + yield devices + destroy_virtual_gamepads(devices) + except ImportError: + pytest.skip("evdev not available — virtual devices disabled") + except PermissionError: + pytest.skip("No uinput access — run as root or load uinput module") + + +@pytest.fixture(autouse=True) +def clean_state(driver): + go_home(driver) + yield + go_home(driver) + + +def go_home(driver): + wait = WebDriverWait(driver, DEFAULT_TIMEOUT) + back_attempts = 0 + max_back = 20 + while back_attempts < max_back: + try: + home_page = wait.until( + EC.presence_of_element_located((AppiumBy.NAME, "Welcome to CouchPlay")) + ) + if home_page.is_displayed(): + return + except Exception: + pass + driver.back() + back_attempts += 1 + time.sleep(0.3) + + +def wait_for_element(driver, by, value, timeout=DEFAULT_TIMEOUT): + return WebDriverWait(driver, timeout).until( + EC.presence_of_element_located((by, value)) + ) + + +def wait_for_element_clickable(driver, by, value, timeout=DEFAULT_TIMEOUT): + return WebDriverWait(driver, timeout).until(EC.element_to_be_clickable((by, value))) + + +def click_by_name(driver, name, timeout=DEFAULT_TIMEOUT): + element = wait_for_element_clickable(driver, AppiumBy.NAME, name, timeout) + element.click() + return element + + +def click_by_object_name(driver, object_name, timeout=DEFAULT_TIMEOUT): + element = wait_for_element_clickable( + driver, AppiumBy.ACCESSIBILITY_ID, object_name, timeout + ) + element.click() + return element + + +def open_global_drawer(driver): + driver.open_notifications() + time.sleep(0.5) + driver.back() + + +def navigate_to_page(driver, action_name, page_title): + open_global_drawer(driver) + click_by_name(driver, action_name) + wait_for_element(driver, AppiumBy.NAME, page_title) + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + result = outcome.get_result() + if result.when == "call" and result.failed: + _driver = item.funcargs.get("driver") + if _driver: + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + filename = f"failed_{item.name}.png" + filepath = os.path.join(SCREENSHOT_DIR, filename) + try: + _driver.save_screenshot(filepath) + except Exception: + pass diff --git a/appiumtests/helpers/__init__.py b/appiumtests/helpers/__init__.py new file mode 100644 index 0000000..11ad4f3 --- /dev/null +++ b/appiumtests/helpers/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors diff --git a/appiumtests/helpers/base_test.py b/appiumtests/helpers/base_test.py new file mode 100644 index 0000000..ae95651 --- /dev/null +++ b/appiumtests/helpers/base_test.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +from appium.webdriver.common.appiumby import AppiumBy +from conftest import ( + DEFAULT_TIMEOUT, + click_by_name, + click_by_object_name, + wait_for_element, + wait_for_element_clickable, + navigate_to_page, +) + + +class BaseTest: + def wait_for_element(self, driver, by, value, timeout=DEFAULT_TIMEOUT): + return wait_for_element(driver, by, value, timeout) + + def wait_for_element_clickable(self, driver, by, value, timeout=DEFAULT_TIMEOUT): + return wait_for_element_clickable(driver, by, value, timeout) + + def click_by_name(self, driver, name, timeout=DEFAULT_TIMEOUT): + return click_by_name(driver, name, timeout) + + def click_by_object_name(self, driver, object_name, timeout=DEFAULT_TIMEOUT): + return click_by_object_name(driver, object_name, timeout) + + def open_global_drawer(self, driver): + navigate_to_page(driver, "Settings", "Settings") + navigate_to_page(driver, "Home", "Welcome to CouchPlay") + + def navigate_to_session_setup(self, driver): + self.click_by_object_name(driver, "cardNewSession") + self.wait_for_element(driver, AppiumBy.NAME, "New Session") + + def navigate_to_profiles(self, driver): + navigate_to_page(driver, "Profiles", "Profiles") + + def navigate_to_users(self, driver): + navigate_to_page(driver, "Users", "Users") + + def navigate_to_settings(self, driver): + navigate_to_page(driver, "Settings", "Settings") + + def navigate_to_device_assignment(self, driver): + self.navigate_to_session_setup(driver) + self.click_by_name(driver, "Assign Devices") + self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices") diff --git a/appiumtests/helpers/mock_helper.py b/appiumtests/helpers/mock_helper.py new file mode 100644 index 0000000..38ee28d --- /dev/null +++ b/appiumtests/helpers/mock_helper.py @@ -0,0 +1,243 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import subprocess +import sys +import time +from pathlib import Path + +import dbus +import dbus.service + +BUS_NAME = "io.github.hikaps.CouchPlayHelper" +OBJECT_PATH = "/io/github/hikaps/CouchPlayHelper" +INTERFACE_NAME = "io.github.hikaps.CouchPlayHelper" + +MOCK_VERSION = "0.2.0-test" + +TEST_USERS = ["player2", "player3"] +BASE_UID = 2000 + + +class MockHelper(dbus.service.Object): + def __init__(self, bus): + super().__init__(bus, OBJECT_PATH) + self._next_pid = 10000 + self._launched_pids = set() + self._created_users = {} + + @dbus.service.method(INTERFACE_NAME, out_signature="s") + def Version(self): + return MOCK_VERSION + + @dbus.service.method(INTERFACE_NAME, in_signature="ss", out_signature="u") + def CreateUser(self, username, fullname): + try: + subprocess.run( + ["groupadd", "-f", "couchplay"], check=False, capture_output=True + ) + result = subprocess.run( + ["useradd", "-m", "-G", "couchplay", "-c", fullname, username], + check=True, + capture_output=True, + ) + subprocess.run( + ["loginctl", "enable-linger", username], + check=False, + capture_output=True, + ) + uid = self._get_uid(username) + self._created_users[username] = uid + return uid + except subprocess.CalledProcessError: + return 0 + + @dbus.service.method(INTERFACE_NAME, in_signature="sb", out_signature="b") + def DeleteUser(self, username, removeHome): + try: + subprocess.run( + ["userdel"] + (["-r"] if removeHome else []) + [username], + check=True, + capture_output=True, + ) + self._created_users.pop(username, None) + return True + except subprocess.CalledProcessError: + return False + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="b") + def IsInCouchPlayGroup(self, username): + result = subprocess.run(["groups", username], capture_output=True, text=True) + return "couchplay" in result.stdout + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="b") + def EnableLinger(self, username): + result = subprocess.run( + ["loginctl", "enable-linger", username], + capture_output=True, + ) + return result.returncode == 0 + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="b") + def IsLingerEnabled(self, username): + result = subprocess.run( + ["loginctl", "show-user", username, "Linger"], + capture_output=True, + text=True, + ) + return "yes" in result.stdout + + @dbus.service.method(INTERFACE_NAME, in_signature="u", out_signature="b") + def SetupRuntimeAccess(self, compositorUid): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="u", out_signature="b") + def RemoveRuntimeAccess(self, compositorUid): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="su", out_signature="b") + def ChangeDeviceOwner(self, devicePath, uid): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="asu", out_signature="i") + def ChangeDeviceOwnerBatch(self, devicePaths, uid): + return len(devicePaths) + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="b") + def ResetDeviceOwner(self, devicePath): + return True + + @dbus.service.method(INTERFACE_NAME, out_signature="i") + def ResetAllDevices(self): + return 0 + + @dbus.service.method( + INTERFACE_NAME, + in_signature="suassas", + out_signature="x", + ) + def LaunchInstance( + self, username, compositorUid, gamescopeArgs, gameCommand, environment + ): + pid = self._next_pid + self._next_pid += 1 + self._launched_pids.add(pid) + return pid + + @dbus.service.method(INTERFACE_NAME, in_signature="x", out_signature="b") + def StopInstance(self, pid): + self._launched_pids.discard(pid) + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="x", out_signature="b") + def KillInstance(self, pid): + self._launched_pids.discard(pid) + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="suas", out_signature="i") + def MountSharedDirectories(self, username, compositorUid, directories): + return len(directories) + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="i") + def UnmountSharedDirectories(self, username): + return 0 + + @dbus.service.method(INTERFACE_NAME, out_signature="i") + def UnmountAllSharedDirectories(self): + return 0 + + @dbus.service.method(INTERFACE_NAME, in_signature="sss", out_signature="b") + def CopyFileToUser(self, sourcePath, targetPath, username): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="ss", out_signature="b") + def CreateUserDirectory(self, path, username): + try: + Path(path).mkdir(parents=True, exist_ok=True) + return True + except OSError: + return False + + @dbus.service.method(INTERFACE_NAME, in_signature="ssb", out_signature="b") + def SetDirectoryAcl(self, path, username, recursive): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="ss", out_signature="b") + def SetPathAclWithParents(self, path, username): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="s") + def GetUserSteamId(self, username): + return "" + + @dbus.service.method(INTERFACE_NAME, in_signature="sssasu", out_signature="b") + def SetupOverlayMount( + self, username, gamePath, gameId, overrideFiles, compositorUid + ): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="ss", out_signature="b") + def TeardownOverlayMount(self, username, gameId): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="s", out_signature="b") + def TeardownAllUserOverlays(self, username): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="sssay", out_signature="b") + def WriteOverrideFile(self, username, gameId, relativePath, content): + return True + + @dbus.service.method(INTERFACE_NAME, in_signature="ss", out_signature="s") + def GetOverlayMountPoint(self, username, gameId): + return f"/tmp/couchplay-overlay/{username}/{gameId}" + + @dbus.service.method(INTERFACE_NAME, in_signature="ayss", out_signature="b") + def WriteFileToUser(self, content, targetPath, username): + return True + + def cleanup(self): + for username in list(self._created_users.keys()): + self.DeleteUser(username, True) + + +def _get_uid(self, username): + try: + result = subprocess.run( + ["id", "-u", username], capture_output=True, text=True, check=True + ) + return int(result.stdout.strip()) + except (subprocess.CalledProcessError, ValueError): + return 0 + + +MockHelper._get_uid = _get_uid + + +def main(): + bus = dbus.SystemBus() + bus_name = dbus.service.BusName(BUS_NAME, bus) + + helper = MockHelper(bus) + + print(f"Mock helper running on {BUS_NAME} at {OBJECT_PATH}", file=sys.stderr) + sys.stdout.flush() + + import signal + + def handle_signal(signum, frame): + helper.cleanup() + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + helper.cleanup() + + +if __name__ == "__main__": + main() diff --git a/appiumtests/helpers/test_users.py b/appiumtests/helpers/test_users.py new file mode 100644 index 0000000..2a82a5e --- /dev/null +++ b/appiumtests/helpers/test_users.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import subprocess + +TEST_USERS = ["player2", "player3"] + + +def create_test_users(): + subprocess.run(["groupadd", "-f", "couchplay"], check=False) + for username in TEST_USERS: + subprocess.run( + ["useradd", "-m", "-G", "couchplay", username], + check=False, + capture_output=True, + ) + subprocess.run( + ["loginctl", "enable-linger", username], + check=False, + capture_output=True, + ) + + +def remove_test_users(): + for username in TEST_USERS: + subprocess.run( + ["userdel", "-r", username], + check=False, + capture_output=True, + ) + + +def get_user_uid(username): + result = subprocess.run(["id", "-u", username], capture_output=True, text=True) + if result.returncode == 0: + try: + return int(result.stdout.strip()) + except ValueError: + return 0 + return 0 diff --git a/appiumtests/helpers/virtual_devices.py b/appiumtests/helpers/virtual_devices.py new file mode 100644 index 0000000..eeeb817 --- /dev/null +++ b/appiumtests/helpers/virtual_devices.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import time + +from evdev import UInput, AbsInfo, ecodes + + +def create_virtual_gamepads(count=2): + devices = [] + for i in range(count): + device = UInput( + { + ecodes.EV_KEY: [ + ecodes.BTN_SOUTH, + ecodes.BTN_EAST, + ecodes.BTN_NORTH, + ecodes.BTN_WEST, + ecodes.BTN_TL, + ecodes.BTN_TR, + ecodes.BTN_START, + ecodes.BTN_SELECT, + ecodes.BTN_MODE, + ecodes.BTN_THUMBL, + ecodes.BTN_THUMBR, + ], + ecodes.EV_ABS: [ + (ecodes.ABS_X, AbsInfo(0, -32768, 32767, 16, 128, 0)), + (ecodes.ABS_Y, AbsInfo(0, -32768, 32767, 16, 128, 0)), + (ecodes.ABS_RX, AbsInfo(0, -32768, 32767, 16, 128, 0)), + (ecodes.ABS_RY, AbsInfo(0, -32768, 32767, 16, 128, 0)), + (ecodes.ABS_Z, AbsInfo(0, 0, 255, 0, 0, 0)), + (ecodes.ABS_RZ, AbsInfo(0, 0, 255, 0, 0, 0)), + (ecodes.ABS_HAT0X, AbsInfo(0, -1, 1, 0, 0, 0)), + (ecodes.ABS_HAT0Y, AbsInfo(0, -1, 1, 0, 0, 0)), + ], + }, + name=f"Virtual CouchPlay Gamepad {i}", + vendor=0x045E, + product=0x028E, + ) + devices.append(device) + time.sleep(0.5) + return devices + + +def destroy_virtual_gamepads(devices): + for device in devices: + try: + device.destroy() + except Exception: + pass diff --git a/appiumtests/requirements.txt b/appiumtests/requirements.txt new file mode 100644 index 0000000..e447c65 --- /dev/null +++ b/appiumtests/requirements.txt @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +Appium-Python-Client>=3.1.0 +pytest>=8.0.0 diff --git a/appiumtests/test_devices.py b/appiumtests/test_devices.py new file mode 100644 index 0000000..138f887 --- /dev/null +++ b/appiumtests/test_devices.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import pytest +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + +pytestmark = pytest.mark.requires_helper + + +class TestDeviceAssignment(BaseTest): + def test_device_page_loads(self, driver): + self.navigate_to_device_assignment(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices") + assert title.is_displayed() + + def test_toolbar_actions_present(self, driver): + self.navigate_to_device_assignment(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Refresh") + self.wait_for_element(driver, AppiumBy.NAME, "Auto-Assign") + self.wait_for_element(driver, AppiumBy.NAME, "Clear All") + + def test_device_tabs_visible(self, driver): + self.navigate_to_device_assignment(driver) + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "tabControllers") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "tabKeyboards") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "tabMice") + + def test_switch_device_tabs(self, driver): + self.navigate_to_device_assignment(driver) + self.click_by_object_name(driver, "tabKeyboards") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "tabKeyboards") + self.click_by_object_name(driver, "tabMice") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "tabMice") + + def test_player_count_spinbox(self, driver): + self.navigate_to_device_assignment(driver) + spin = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "spinInstanceCount" + ) + assert spin.is_displayed() + + def test_show_virtual_devices_checkbox(self, driver): + self.navigate_to_device_assignment(driver) + checkbox = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "checkShowVirtual" + ) + assert checkbox.is_displayed() diff --git a/appiumtests/test_home.py b/appiumtests/test_home.py new file mode 100644 index 0000000..b5ca18c --- /dev/null +++ b/appiumtests/test_home.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + + +class TestHomePage(BaseTest): + def test_app_launches_home_visible(self, driver): + heading = self.wait_for_element(driver, AppiumBy.NAME, "Welcome to CouchPlay") + assert heading.is_displayed() + + def test_home_shows_action_cards(self, driver): + self.click_by_object_name(driver, "cardNewSession") + self.wait_for_element(driver, AppiumBy.NAME, "New Session") + + def test_navigate_to_session_setup_via_card(self, driver): + self.click_by_object_name(driver, "cardNewSession") + title = self.wait_for_element(driver, AppiumBy.NAME, "New Session") + assert title.is_displayed() + + def test_navigate_to_profiles_via_card(self, driver): + self.click_by_object_name(driver, "cardLoadProfile") + title = self.wait_for_element(driver, AppiumBy.NAME, "Profiles") + assert title.is_displayed() + + def test_navigate_to_devices_via_card(self, driver): + self.click_by_object_name(driver, "cardManageDevices") + title = self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices") + assert title.is_displayed() + + def test_navigate_to_profiles_via_drawer(self, driver): + self.navigate_to_profiles(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Profiles") + assert title.is_displayed() + + def test_navigate_to_users_via_drawer(self, driver): + self.navigate_to_users(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Users") + assert title.is_displayed() + + def test_navigate_to_settings_via_drawer(self, driver): + self.navigate_to_settings(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Settings") + assert title.is_displayed() diff --git a/appiumtests/test_profiles.py b/appiumtests/test_profiles.py new file mode 100644 index 0000000..66652ae --- /dev/null +++ b/appiumtests/test_profiles.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + + +class TestProfiles(BaseTest): + def test_profiles_page_loads(self, driver): + self.navigate_to_profiles(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Profiles") + assert title.is_displayed() + + def test_empty_state_visible(self, driver): + self.navigate_to_profiles(driver) + empty_msg = self.wait_for_element(driver, AppiumBy.NAME, "No Saved Profiles") + assert empty_msg.is_displayed() + + def test_toolbar_actions_present(self, driver): + self.navigate_to_profiles(driver) + self.wait_for_element(driver, AppiumBy.NAME, "New Profile") + self.wait_for_element(driver, AppiumBy.NAME, "Refresh") + + def test_new_profile_navigates_to_session_setup(self, driver): + self.navigate_to_profiles(driver) + self.click_by_name(driver, "New Profile") + title = self.wait_for_element(driver, AppiumBy.NAME, "New Session") + assert title.is_displayed() diff --git a/appiumtests/test_session.py b/appiumtests/test_session.py new file mode 100644 index 0000000..9da3e6c --- /dev/null +++ b/appiumtests/test_session.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import pytest +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + +LONG_TIMEOUT = 20 + + +class TestSessionLifecycle(BaseTest): + def test_session_setup_with_helper(self, driver, mock_helper, test_users): + self.navigate_to_session_setup(driver) + title = self.wait_for_element( + driver, AppiumBy.NAME, "New Session", LONG_TIMEOUT + ) + assert title.is_displayed() + + def test_start_and_stop_session(self, driver, mock_helper, test_users): + self.navigate_to_session_setup(driver) + + self.wait_for_element(driver, AppiumBy.NAME, "Start Session", LONG_TIMEOUT) + self.click_by_name(driver, "Start Session", LONG_TIMEOUT) + + self.wait_for_element(driver, AppiumBy.NAME, "Stop Session", LONG_TIMEOUT) + stop_btn = self.wait_for_element_clickable( + driver, AppiumBy.NAME, "Stop Session", LONG_TIMEOUT + ) + assert stop_btn.is_displayed() + + stop_btn.click() + + self.wait_for_element(driver, AppiumBy.NAME, "Start Session", LONG_TIMEOUT) + + def test_session_without_users_shows_error(self, driver, mock_helper): + self.navigate_to_session_setup(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Start Session", LONG_TIMEOUT) + self.click_by_name(driver, "Start Session", LONG_TIMEOUT) + start_btn = self.wait_for_element( + driver, AppiumBy.NAME, "Start Session", LONG_TIMEOUT + ) + assert start_btn.is_displayed() + + def test_device_assignment_page_with_helper(self, driver, mock_helper): + self.navigate_to_device_assignment(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices", LONG_TIMEOUT) + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "actionAutoAssign", LONG_TIMEOUT + ) + + def test_auto_assign_with_virtual_devices( + self, driver, mock_helper, test_users, virtual_gamepads + ): + self.navigate_to_device_assignment(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices", LONG_TIMEOUT) + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "tabControllers", LONG_TIMEOUT + ) + self.click_by_name(driver, "Auto-Assign", LONG_TIMEOUT) + + def test_users_page_with_helper(self, driver, mock_helper, test_users): + self.navigate_to_users(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Users", LONG_TIMEOUT) + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "actionAddUser", LONG_TIMEOUT + ) + + def test_create_user_dialog_with_helper(self, driver, mock_helper): + self.navigate_to_users(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Users", LONG_TIMEOUT) + self.click_by_name(driver, "Add User", LONG_TIMEOUT) + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "dialogAddUser", LONG_TIMEOUT + ) + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "fieldUsername", LONG_TIMEOUT + ) diff --git a/appiumtests/test_session_setup.py b/appiumtests/test_session_setup.py new file mode 100644 index 0000000..a8445e3 --- /dev/null +++ b/appiumtests/test_session_setup.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + + +class TestSessionSetup(BaseTest): + def test_session_setup_page_loads(self, driver): + self.navigate_to_session_setup(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "New Session") + assert title.is_displayed() + + def test_player_count_default(self, driver): + self.navigate_to_session_setup(driver) + spin = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "spinPlayerCount" + ) + assert spin.is_displayed() + + def test_layout_cards_visible(self, driver): + self.navigate_to_session_setup(driver) + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "cardLayoutHorizontal") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "cardLayoutVertical") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "cardLayoutGrid") + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "cardLayoutMultiMonitor" + ) + + def test_select_layout(self, driver): + self.navigate_to_session_setup(driver) + self.click_by_object_name(driver, "cardLayoutVertical") + card = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "cardLayoutVertical" + ) + assert card.is_displayed() + + def test_toolbar_actions_present(self, driver): + self.navigate_to_session_setup(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Start Session") + self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices") + self.wait_for_element(driver, AppiumBy.NAME, "Save Profile") + + def test_save_profile_dialog_opens(self, driver): + self.navigate_to_session_setup(driver) + self.click_by_name(driver, "Save Profile") + dialog = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "dialogSaveProfile" + ) + assert dialog.is_displayed() + + def test_navigate_to_device_assignment(self, driver): + self.navigate_to_session_setup(driver) + self.click_by_name(driver, "Assign Devices") + title = self.wait_for_element(driver, AppiumBy.NAME, "Assign Devices") + assert title.is_displayed() + + def test_instance_config_visible_for_two_players(self, driver): + self.navigate_to_session_setup(driver) + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "comboUser") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "comboLauncher") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "comboScaling") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "checkBorderless") diff --git a/appiumtests/test_settings.py b/appiumtests/test_settings.py new file mode 100644 index 0000000..5cccc41 --- /dev/null +++ b/appiumtests/test_settings.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + + +class TestSettings(BaseTest): + def test_settings_page_loads(self, driver): + self.navigate_to_settings(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Settings") + assert title.is_displayed() + + def test_general_section_visible(self, driver): + self.navigate_to_settings(driver) + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "checkHidePanels") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "checkKillSteam") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "checkRestoreSession") + + def test_gamescope_section_visible(self, driver): + self.navigate_to_settings(driver) + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "comboScaling") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "comboFilter") + self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "checkSteamIntegration" + ) + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "checkBorderless") + + def test_reset_action_present(self, driver): + self.navigate_to_settings(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Reset to Defaults") + + def test_reset_dialog_opens(self, driver): + self.navigate_to_settings(driver) + self.click_by_name(driver, "Reset to Defaults") + dialog = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "dialogResetSettings" + ) + assert dialog.is_displayed() diff --git a/appiumtests/test_users.py b/appiumtests/test_users.py new file mode 100644 index 0000000..5c3883f --- /dev/null +++ b/appiumtests/test_users.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import pytest +from appium.webdriver.common.appiumby import AppiumBy +from helpers.base_test import BaseTest + +pytestmark = pytest.mark.requires_helper + + +class TestUsers(BaseTest): + def test_users_page_loads(self, driver): + self.navigate_to_users(driver) + title = self.wait_for_element(driver, AppiumBy.NAME, "Users") + assert title.is_displayed() + + def test_toolbar_actions_present(self, driver): + self.navigate_to_users(driver) + self.wait_for_element(driver, AppiumBy.NAME, "Add User") + self.wait_for_element(driver, AppiumBy.NAME, "Refresh") + + def test_add_user_dialog_opens(self, driver): + self.navigate_to_users(driver) + self.click_by_name(driver, "Add User") + dialog = self.wait_for_element( + driver, AppiumBy.ACCESSIBILITY_ID, "dialogAddUser" + ) + assert dialog.is_displayed() + + def test_add_user_dialog_has_fields(self, driver): + self.navigate_to_users(driver) + self.click_by_name(driver, "Add User") + self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "fieldUsername") + + def test_helper_status_visible(self, driver): + self.navigate_to_users(driver) + self.wait_for_element(driver, AppiumBy.NAME, "CouchPlay Users") diff --git a/src/qml/Main.qml b/src/qml/Main.qml index b320143..b67ad8a 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -142,6 +142,10 @@ Kirigami.ApplicationWindow { actions: [ Kirigami.Action { + objectName: "actionHome" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Home") + Accessible.onPressAction: triggered() icon.name: "go-home" text: i18nc("@action:button", "Home") onTriggered: { @@ -155,6 +159,10 @@ Kirigami.ApplicationWindow { } }, Kirigami.Action { + objectName: "actionNewSession" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "New Session") + Accessible.onPressAction: triggered() icon.name: "list-add" text: i18nc("@action:button", "New Session") onTriggered: { @@ -170,6 +178,10 @@ Kirigami.ApplicationWindow { } }, Kirigami.Action { + objectName: "actionProfiles" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Profiles") + Accessible.onPressAction: triggered() icon.name: "bookmark" text: i18nc("@action:button", "Profiles") onTriggered: { @@ -181,6 +193,10 @@ Kirigami.ApplicationWindow { } }, Kirigami.Action { + objectName: "actionUsers" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Users") + Accessible.onPressAction: triggered() icon.name: "system-users" text: i18nc("@action:button", "Users") onTriggered: { @@ -192,6 +208,10 @@ Kirigami.ApplicationWindow { } }, Kirigami.Action { + objectName: "actionSettings" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Settings") + Accessible.onPressAction: triggered() icon.name: "configure" text: i18nc("@action:button", "Settings") onTriggered: { diff --git a/src/qml/components/ActionCard.qml b/src/qml/components/ActionCard.qml index f385a42..77e8100 100644 --- a/src/qml/components/ActionCard.qml +++ b/src/qml/components/ActionCard.qml @@ -19,6 +19,10 @@ import org.kde.kirigami as Kirigami Kirigami.AbstractCard { id: root + Accessible.role: Accessible.Button + Accessible.name: root.title + Accessible.onPressAction: clicked() + required property string iconName required property string title property string description: "" diff --git a/src/qml/components/PresetSelector.qml b/src/qml/components/PresetSelector.qml index 311ef40..d48412e 100644 --- a/src/qml/components/PresetSelector.qml +++ b/src/qml/components/PresetSelector.qml @@ -19,6 +19,10 @@ import org.kde.kirigami as Kirigami Controls.ComboBox { id: root + objectName: "comboPresetSelector" + Accessible.role: Accessible.ComboBox + Accessible.name: i18nc("@label", "Launcher") + required property var presetManager property string currentPresetId: "steam" diff --git a/src/qml/components/SelectableCard.qml b/src/qml/components/SelectableCard.qml index a4d451d..9ebe770 100644 --- a/src/qml/components/SelectableCard.qml +++ b/src/qml/components/SelectableCard.qml @@ -19,6 +19,10 @@ import org.kde.kirigami as Kirigami Kirigami.AbstractCard { id: root + Accessible.role: Accessible.Button + Accessible.name: root.title + Accessible.onPressAction: clicked() + // Required properties required property string title diff --git a/src/qml/components/dialogs/AddPresetDialog.qml b/src/qml/components/dialogs/AddPresetDialog.qml index a00736e..bd24ef4 100644 --- a/src/qml/components/dialogs/AddPresetDialog.qml +++ b/src/qml/components/dialogs/AddPresetDialog.qml @@ -8,7 +8,10 @@ import org.kde.kirigami as Kirigami Kirigami.Dialog { id: root + objectName: "dialogAddPreset" title: i18nc("@title:dialog", "Add Preset from Application") + Accessible.role: Accessible.Dialog + Accessible.name: title standardButtons: Kirigami.Dialog.Close preferredWidth: Kirigami.Units.gridUnit * 30 preferredHeight: Kirigami.Units.gridUnit * 25 @@ -20,8 +23,11 @@ Kirigami.Dialog { Controls.TextField { id: appSearchField + objectName: "fieldAppSearch" Layout.fillWidth: true placeholderText: i18nc("@info:placeholder", "Search applications...") + Accessible.role: Accessible.EditableText + Accessible.name: placeholderText onTextChanged: appListView.filterText = text.toLowerCase() } @@ -78,8 +84,12 @@ Kirigami.Dialog { } Controls.Button { + objectName: "btnAddApplication" text: i18nc("@action:button", "Add") icon.name: "list-add" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: { let id = root.presetManager.addPresetFromDesktopFile(modelData.desktopFilePath) if (id !== "") { diff --git a/src/qml/components/dialogs/DeletePresetDialog.qml b/src/qml/components/dialogs/DeletePresetDialog.qml index 41cbccb..88a2935 100644 --- a/src/qml/components/dialogs/DeletePresetDialog.qml +++ b/src/qml/components/dialogs/DeletePresetDialog.qml @@ -7,7 +7,10 @@ import org.kde.kirigami as Kirigami Kirigami.PromptDialog { id: root + objectName: "dialogDeletePreset" title: i18nc("@title:dialog", "Remove Preset") + Accessible.role: Accessible.Dialog + Accessible.name: title subtitle: i18nc("@info", "Remove the preset \"%1\"?", presetName) standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel diff --git a/src/qml/components/dialogs/EditPresetDialog.qml b/src/qml/components/dialogs/EditPresetDialog.qml index 7b8c7f2..0fa37a0 100644 --- a/src/qml/components/dialogs/EditPresetDialog.qml +++ b/src/qml/components/dialogs/EditPresetDialog.qml @@ -9,7 +9,10 @@ import org.kde.kirigami as Kirigami Kirigami.Dialog { id: root + objectName: "dialogEditPreset" title: i18nc("@title:dialog", "Edit Preset: %1", presetName) + Accessible.role: Accessible.Dialog + Accessible.name: title standardButtons: Kirigami.Dialog.Close preferredWidth: Kirigami.Units.gridUnit * 30 preferredHeight: Kirigami.Units.gridUnit * 25 @@ -21,8 +24,12 @@ Kirigami.Dialog { property string presetName: "" footerLeadingComponent: Controls.Button { + objectName: "btnAddDirectory" text: i18nc("@action:button", "Add Directory...") icon.name: "folder-add" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: folderDialog.open() } @@ -88,8 +95,12 @@ Kirigami.Dialog { } Controls.Button { + objectName: "btnRemoveDirectory" icon.name: "edit-delete" display: Controls.AbstractButton.IconOnly + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Remove directory") + Accessible.onPressAction: clicked() onClicked: { directoriesModel.remove(index) root.presetManager.setSharedDirectories(root.presetId, root.getDirectoriesArray()) diff --git a/src/qml/components/dialogs/InstallHelperDialog.qml b/src/qml/components/dialogs/InstallHelperDialog.qml index c098a89..c74b545 100644 --- a/src/qml/components/dialogs/InstallHelperDialog.qml +++ b/src/qml/components/dialogs/InstallHelperDialog.qml @@ -8,7 +8,10 @@ import org.kde.kirigami as Kirigami Kirigami.Dialog { id: root + objectName: "dialogInstallHelper" title: i18nc("@title:dialog", "Install Helper Service") + Accessible.role: Accessible.Dialog + Accessible.name: title standardButtons: Kirigami.Dialog.Close preferredWidth: Kirigami.Units.gridUnit * 30 diff --git a/src/qml/components/dialogs/ResetSettingsDialog.qml b/src/qml/components/dialogs/ResetSettingsDialog.qml index 0a75642..8721f00 100644 --- a/src/qml/components/dialogs/ResetSettingsDialog.qml +++ b/src/qml/components/dialogs/ResetSettingsDialog.qml @@ -8,10 +8,13 @@ import org.kde.kirigami as Kirigami Kirigami.PromptDialog { id: root + objectName: "dialogResetSettings" required property var settingsManager title: i18nc("@title:dialog", "Reset Settings") + Accessible.role: Accessible.Dialog + Accessible.name: title subtitle: i18nc("@info", "Reset all settings to their default values?") standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel diff --git a/src/qml/pages/DeviceAssignmentPage.qml b/src/qml/pages/DeviceAssignmentPage.qml index 857c772..425f384 100644 --- a/src/qml/pages/DeviceAssignmentPage.qml +++ b/src/qml/pages/DeviceAssignmentPage.qml @@ -19,11 +19,19 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionRefresh" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Refresh") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "Refresh") icon.name: "view-refresh" onTriggered: deviceManager?.refresh() }, Kirigami.Action { + objectName: "actionAutoAssign" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Auto-Assign") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "Auto-Assign") icon.name: "distribute-horizontal" tooltip: i18nc("@info:tooltip", "Automatically assign one controller per player") @@ -40,6 +48,10 @@ Kirigami.ScrollablePage { } }, Kirigami.Action { + objectName: "actionClearAll" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Clear All") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "Clear All") icon.name: "edit-clear-all" onTriggered: deviceManager?.unassignAll() @@ -56,6 +68,9 @@ Kirigami.ScrollablePage { QQC2.SpinBox { id: instanceCountSpin + objectName: "spinInstanceCount" + Accessible.role: Accessible.SpinBox + Accessible.name: i18nc("@label", "Players") from: 2 to: 4 value: root.instanceCount @@ -68,6 +83,10 @@ Kirigami.ScrollablePage { Item { Layout.fillWidth: true } QQC2.CheckBox { + id: checkShowVirtual + objectName: "checkShowVirtual" + Accessible.role: Accessible.CheckBox + Accessible.name: i18nc("@option:check", "Show virtual devices") text: i18nc("@option:check", "Show virtual devices") checked: deviceManager?.showVirtualDevices ?? false onToggled: { if (deviceManager) deviceManager.showVirtualDevices = checked } @@ -129,14 +148,23 @@ Kirigami.ScrollablePage { Layout.fillWidth: true QQC2.TabButton { + objectName: "tabControllers" + Accessible.role: Accessible.PageTab + Accessible.name: "Controllers" text: "Controllers (" + (deviceManager?.controllers?.length ?? 0) + ")" icon.name: "input-gamepad" } QQC2.TabButton { + objectName: "tabKeyboards" + Accessible.role: Accessible.PageTab + Accessible.name: "Keyboards" text: "Keyboards (" + (deviceManager?.keyboards?.length ?? 0) + ")" icon.name: "input-keyboard" } QQC2.TabButton { + objectName: "tabMice" + Accessible.role: Accessible.PageTab + Accessible.name: "Mice" text: "Mice (" + (deviceManager?.mice?.length ?? 0) + ")" icon.name: "input-mouse" } @@ -195,6 +223,10 @@ Kirigami.ScrollablePage { icon.name: "input-gamepad" helpfulAction: Kirigami.Action { + objectName: "actionRefreshDevices" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Refresh Devices") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "Refresh Devices") icon.name: "view-refresh" onTriggered: deviceManager?.refresh() @@ -399,6 +431,10 @@ Kirigami.ScrollablePage { // Assignment indicator Kirigami.Chip { + id: chipPlayerAssignment + objectName: "chipPlayerAssignment" + Accessible.role: Accessible.Button + Accessible.name: text visible: deviceCard.device?.assigned ?? false text: i18nc("@info", "Player %1", (deviceCard.device?.assignedInstance ?? 0) + 1) closable: true @@ -414,6 +450,10 @@ Kirigami.ScrollablePage { model: deviceCard.instanceCount QQC2.Button { + objectName: "btnAssignPlayer" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Assign to Player %1", index + 1) + Accessible.onPressAction: clicked() required property int index text: (index + 1).toString() visible: !(deviceCard.device?.assigned ?? true) @@ -432,6 +472,10 @@ Kirigami.ScrollablePage { // Identify button (controllers only) QQC2.Button { + objectName: "btnIdentifyDevice" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Identify controller") + Accessible.onPressAction: clicked() icon.name: "flashlight-on" flat: true visible: deviceCard.device?.type === "controller" @@ -448,6 +492,10 @@ Kirigami.ScrollablePage { // Ignore button QQC2.Button { + objectName: "btnIgnoreDevice" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Ignore this device") + Accessible.onPressAction: clicked() icon.name: "dialog-cancel" flat: true visible: !(deviceCard.device?.assigned ?? true) // Only allow ignoring unassigned devices diff --git a/src/qml/pages/HomePage.qml b/src/qml/pages/HomePage.qml index f80ef46..56e33b5 100644 --- a/src/qml/pages/HomePage.qml +++ b/src/qml/pages/HomePage.qml @@ -63,6 +63,11 @@ Kirigami.ScrollablePage { // Session status indicator Kirigami.Chip { + id: chipSessionStatus + objectName: "chipSessionStatus" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@info", "%1 instances running", sessionRunner ? sessionRunner.runningInstanceCount : 0) + Accessible.onPressAction: removed() visible: sessionRunner?.running ?? false text: i18nc("@info", "%1 instances running", sessionRunner ? sessionRunner.runningInstanceCount : 0) icon.name: "media-playback-start" @@ -85,11 +90,19 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionViewSession" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "View Session") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "View Session") icon.name: "view-visible" onTriggered: applicationWindow().pushSessionSetupPage() }, Kirigami.Action { + objectName: "actionStopSession" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Stop") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "Stop") icon.name: "media-playback-stop" onTriggered: sessionRunner.stop() @@ -108,6 +121,7 @@ Kirigami.ScrollablePage { Layout.fillWidth: true Components.ActionCard { + objectName: "cardNewSession" Layout.fillWidth: true Layout.preferredHeight: Kirigami.Units.gridUnit * 8 iconName: "list-add" @@ -123,6 +137,7 @@ Kirigami.ScrollablePage { } Components.ActionCard { + objectName: "cardLoadProfile" Layout.fillWidth: true Layout.preferredHeight: Kirigami.Units.gridUnit * 8 iconName: "bookmark" @@ -136,6 +151,7 @@ Kirigami.ScrollablePage { } Components.ActionCard { + objectName: "cardManageDevices" Layout.fillWidth: true Layout.preferredHeight: Kirigami.Units.gridUnit * 8 iconName: "input-gamepad" @@ -207,6 +223,10 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnLaunchProfile" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Launch this profile") + Accessible.onPressAction: clicked() icon.name: "media-playback-start" flat: true display: Controls.AbstractButton.IconOnly @@ -234,6 +254,10 @@ Kirigami.ScrollablePage { Layout.fillWidth: true helpfulAction: Kirigami.Action { + objectName: "actionCreateNewSession" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Create New Session") + Accessible.onPressAction: triggered() icon.name: "list-add" text: i18nc("@action:button", "Create New Session") onTriggered: { @@ -245,6 +269,10 @@ Kirigami.ScrollablePage { // View all profiles link Controls.Button { + objectName: "btnViewAllProfiles" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "View all %1 profiles...", sessionManager ? sessionManager.savedProfiles.length : 0) + Accessible.onPressAction: clicked() visible: (sessionManager?.savedProfiles?.length ?? 0) > 4 text: i18nc("@action:button", "View all %1 profiles...", sessionManager ? sessionManager.savedProfiles.length : 0) flat: true @@ -336,6 +364,10 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnSetup" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Setup") + Accessible.onPressAction: clicked() visible: !root.helperAvailable text: i18nc("@action:button", "Setup") flat: true diff --git a/src/qml/pages/ProfilesPage.qml b/src/qml/pages/ProfilesPage.qml index 0a3c012..46ab4bc 100644 --- a/src/qml/pages/ProfilesPage.qml +++ b/src/qml/pages/ProfilesPage.qml @@ -23,6 +23,10 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionNewProfile" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "New Profile") + Accessible.onPressAction: triggered() icon.name: "list-add" text: i18nc("@action:button", "New Profile") onTriggered: { @@ -30,6 +34,10 @@ Kirigami.ScrollablePage { } }, Kirigami.Action { + objectName: "actionRefresh" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Refresh") + Accessible.onPressAction: triggered() icon.name: "view-refresh" text: i18nc("@action:button", "Refresh") onTriggered: sessionManager?.refreshProfiles() @@ -39,6 +47,9 @@ Kirigami.ScrollablePage { // Delete confirmation dialog Kirigami.PromptDialog { id: deleteDialog + objectName: "dialogDeleteProfile" + Accessible.role: Accessible.Dialog + Accessible.name: i18nc("@title:dialog", "Delete Profile") title: i18nc("@title:dialog", "Delete Profile") subtitle: i18nc("@info", "Are you sure you want to delete the profile '%1'?", deleteDialog.profileName) standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No @@ -117,6 +128,10 @@ Kirigami.ScrollablePage { icon.name: "bookmark" helpfulAction: Kirigami.Action { + objectName: "actionCreateNewSession" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Create New Session") + Accessible.onPressAction: triggered() icon.name: "list-add" text: i18nc("@action:button", "Create New Session") onTriggered: { @@ -237,6 +252,10 @@ Kirigami.ScrollablePage { spacing: Kirigami.Units.smallSpacing Controls.Button { + objectName: "btnLaunchProfile" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Launch") + Accessible.onPressAction: clicked() text: i18nc("@action:button", "Launch") icon.name: "media-playback-start" highlighted: true @@ -244,6 +263,10 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnEditProfile" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Edit") + Accessible.onPressAction: clicked() text: i18nc("@action:button", "Edit") icon.name: "document-edit" flat: true @@ -253,6 +276,10 @@ Kirigami.ScrollablePage { Item { Layout.fillWidth: true } Controls.Button { + objectName: "btnDeleteProfile" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@info:tooltip", "Delete profile") + Accessible.onPressAction: clicked() icon.name: "edit-delete" flat: true display: Controls.AbstractButton.IconOnly diff --git a/src/qml/pages/SessionSetupPage.qml b/src/qml/pages/SessionSetupPage.qml index 60d43dd..df11266 100644 --- a/src/qml/pages/SessionSetupPage.qml +++ b/src/qml/pages/SessionSetupPage.qml @@ -95,6 +95,12 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionStartSession" + Accessible.role: Accessible.Button + Accessible.name: sessionRunner && sessionRunner.running + ? i18nc("@action:button", "Stop Session") + : i18nc("@action:button", "Start Session") + Accessible.onPressAction: triggered() icon.name: "media-playback-start" text: sessionRunner && sessionRunner.running ? i18nc("@action:button", "Stop Session") @@ -108,6 +114,10 @@ Kirigami.ScrollablePage { } }, Kirigami.Action { + objectName: "actionAssignDevices" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Assign Devices") + Accessible.onPressAction: triggered() icon.name: "go-next" text: i18nc("@action:button", "Assign Devices") onTriggered: { @@ -115,6 +125,10 @@ Kirigami.ScrollablePage { } }, Kirigami.Action { + objectName: "actionSaveProfile" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Save Profile") + Accessible.onPressAction: triggered() icon.name: "document-save" text: i18nc("@action:button", "Save Profile") onTriggered: saveProfileDialog.open() @@ -124,6 +138,9 @@ Kirigami.ScrollablePage { // Save profile dialog Kirigami.PromptDialog { id: saveProfileDialog + objectName: "dialogSaveProfile" + Accessible.role: Accessible.Dialog + Accessible.name: i18nc("@title:dialog", "Save Profile") title: i18nc("@title:dialog", "Save Profile") subtitle: sessionManager?.currentProfileName ? i18nc("@info", "Save changes to '%1' or enter a new name", sessionManager.currentProfileName) @@ -132,6 +149,9 @@ Kirigami.ScrollablePage { Controls.TextField { id: profileNameField + objectName: "fieldProfileName" + Accessible.role: Accessible.EditableText + Accessible.name: i18nc("@label", "Profile name") placeholderText: i18nc("@info:placeholder", "Profile name") text: sessionManager?.currentProfileName ?? "" Layout.fillWidth: true @@ -164,6 +184,10 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionStop" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Stop") + Accessible.onPressAction: triggered() text: i18nc("@action:button", "Stop") icon.name: "media-playback-stop" onTriggered: sessionRunner.stop() @@ -195,6 +219,9 @@ Kirigami.ScrollablePage { Controls.SpinBox { id: playerCountSpin + objectName: "spinPlayerCount" + Accessible.role: Accessible.SpinBox + Accessible.name: i18nc("@label", "Number of players") from: 2 to: 4 value: root.instanceCount @@ -209,6 +236,7 @@ Kirigami.ScrollablePage { // Horizontal split LayoutCard { + objectName: "cardLayoutHorizontal" Layout.fillWidth: true layoutType: "horizontal" selected: layoutMode === "horizontal" @@ -220,6 +248,7 @@ Kirigami.ScrollablePage { // Vertical split LayoutCard { + objectName: "cardLayoutVertical" Layout.fillWidth: true layoutType: "vertical" selected: layoutMode === "vertical" @@ -231,6 +260,7 @@ Kirigami.ScrollablePage { // Grid (for 3-4 players) LayoutCard { + objectName: "cardLayoutGrid" Layout.fillWidth: true layoutType: "grid" selected: layoutMode === "grid" @@ -243,6 +273,7 @@ Kirigami.ScrollablePage { // Multi-monitor LayoutCard { + objectName: "cardLayoutMultiMonitor" Layout.fillWidth: true layoutType: "multi-monitor" selected: layoutMode === "multi-monitor" @@ -334,6 +365,9 @@ Kirigami.ScrollablePage { Controls.ComboBox { id: userCombo + objectName: "comboUser" + Accessible.role: Accessible.ComboBox + Accessible.name: instanceCard.labelUser Kirigami.FormData.label: instanceCard.labelUser Layout.fillWidth: true @@ -385,6 +419,7 @@ Kirigami.ScrollablePage { // Launch preset selector Components.PresetSelector { id: presetSelector + objectName: "comboLauncher" Kirigami.FormData.label: instanceCard.labelLauncher Layout.fillWidth: true presetManager: instanceCard.cardPresetManager @@ -411,6 +446,9 @@ Kirigami.ScrollablePage { Controls.TextField { id: patternInput + objectName: "fieldPattern" + Accessible.role: Accessible.EditableText + Accessible.name: instanceCard.labelOverlay placeholderText: instanceCard.placeholderPattern Layout.fillWidth: true onAccepted: { @@ -425,6 +463,10 @@ Kirigami.ScrollablePage { } } Controls.Button { + objectName: "btnAddPattern" + Accessible.role: Accessible.Button + Accessible.name: instanceCard.buttonAdd + Accessible.onPressAction: clicked() text: instanceCard.buttonAdd flat: true onClicked: { @@ -457,6 +499,9 @@ Kirigami.ScrollablePage { Controls.SpinBox { id: refreshSpin + objectName: "spinRefreshRate" + Accessible.role: Accessible.SpinBox + Accessible.name: instanceCard.labelRefreshRate Kirigami.FormData.label: instanceCard.labelRefreshRate from: 30 to: 240 @@ -474,6 +519,9 @@ Kirigami.ScrollablePage { } Controls.ComboBox { + objectName: "comboScaling" + Accessible.role: Accessible.ComboBox + Accessible.name: instanceCard.labelScaling Kirigami.FormData.label: instanceCard.labelScaling model: ["fit", "stretch", "integer", "auto"] currentIndex: 0 @@ -481,6 +529,10 @@ Kirigami.ScrollablePage { } Controls.CheckBox { + id: checkBorderless + objectName: "checkBorderless" + Accessible.role: Accessible.CheckBox + Accessible.name: i18nc("@option:check", "Borderless") Kirigami.FormData.label: instanceCard.labelWindowBorders checked: root.sessionManager ? root.sessionManager.getInstanceConfig(instanceCard.index).borderless : false text: i18nc("@option:check", "Borderless") @@ -516,6 +568,10 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnAssignDevices" + Accessible.role: Accessible.Button + Accessible.name: instanceCard.textAssign + Accessible.onPressAction: clicked() text: instanceCard.textAssign flat: true onClicked: applicationWindow().pushDeviceAssignmentPage() @@ -562,6 +618,10 @@ Kirigami.ScrollablePage { } Item { Layout.fillWidth: true } Controls.Button { + objectName: "btnOpenFolder" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Open Folder") + Accessible.onPressAction: clicked() text: i18nc("@action:button", "Open Folder") icon.name: "folder-open" flat: true @@ -595,6 +655,9 @@ Kirigami.ScrollablePage { font.family: "monospace" } Controls.ToolButton { + objectName: "btnRemovePattern" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Remove pattern") icon.name: "list-remove" onClicked: { if (!instanceCard.cardSessionManager) return @@ -704,6 +767,10 @@ Kirigami.ScrollablePage { spacing: Kirigami.Units.largeSpacing Controls.Button { + objectName: "btnAutoAssign" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Auto-Assign Controllers") + Accessible.onPressAction: clicked() text: i18nc("@action:button", "Auto-Assign Controllers") icon.name: "input-gamepad" onClicked: { @@ -719,6 +786,12 @@ Kirigami.ScrollablePage { Item { Layout.fillWidth: true } Controls.Button { + objectName: "btnStartSession" + Accessible.role: Accessible.Button + Accessible.name: sessionRunner && sessionRunner.running + ? i18nc("@action:button", "Stop Session") + : i18nc("@action:button", "Start Session") + Accessible.onPressAction: clicked() text: sessionRunner && sessionRunner.running ? i18nc("@action:button", "Stop Session") : i18nc("@action:button", "Start Session") @@ -747,6 +820,10 @@ Kirigami.ScrollablePage { required property string description required property int instanceCount + Accessible.role: Accessible.Button + Accessible.name: layoutCard.title + Accessible.onPressAction: layoutCard.clicked() + Layout.preferredHeight: Kirigami.Units.gridUnit * 10 // Custom animated background for selection feedback diff --git a/src/qml/pages/SettingsPage.qml b/src/qml/pages/SettingsPage.qml index f37a151..3cbde27 100644 --- a/src/qml/pages/SettingsPage.qml +++ b/src/qml/pages/SettingsPage.qml @@ -53,8 +53,12 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionResetDefaults" icon.name: "edit-undo" text: i18nc("@action:button", "Reset to Defaults") + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: triggered() onTriggered: resetConfirmDialog.open() } ] @@ -74,7 +78,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: hidePanelsCheck + objectName: "checkHidePanels" Kirigami.FormData.label: i18nc("@option:check", "Hide panels during session") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.hidePanels onToggled: if (root.settingsManager) root.settingsManager.hidePanels = checked @@ -85,7 +92,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: killSteamOption + objectName: "checkKillSteam" Kirigami.FormData.label: i18nc("@option:check", "Close Steam before starting") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.killSteam onToggled: if (root.settingsManager) root.settingsManager.killSteam = checked @@ -96,7 +106,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: restoreSessionCheck + objectName: "checkRestoreSession" Kirigami.FormData.label: i18nc("@option:check", "Restore last session on startup") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.restoreSession onToggled: if (root.settingsManager) root.settingsManager.restoreSession = checked @@ -118,7 +131,10 @@ Kirigami.ScrollablePage { Controls.ComboBox { id: scalingCombo + objectName: "comboScaling" Kirigami.FormData.label: i18nc("@label", "Scaling mode") + Accessible.role: Accessible.ComboBox + Accessible.name: Kirigami.FormData.label model: [ { value: "fit", text: i18nc("@item:inlistbox", "Fit (maintain aspect ratio)") }, { value: "fill", text: i18nc("@item:inlistbox", "Fill (crop to fill)") }, @@ -133,7 +149,10 @@ Kirigami.ScrollablePage { Controls.ComboBox { id: filterCombo + objectName: "comboFilter" Kirigami.FormData.label: i18nc("@label", "Upscaling filter") + Accessible.role: Accessible.ComboBox + Accessible.name: Kirigami.FormData.label model: [ { value: "linear", text: i18nc("@item:inlistbox", "Linear (smooth)") }, { value: "nearest", text: i18nc("@item:inlistbox", "Nearest (sharp/pixelated)") }, @@ -148,7 +167,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: steamIntegrationCheck + objectName: "checkSteamIntegration" Kirigami.FormData.label: i18nc("@option:check", "Steam integration") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.steamIntegration onToggled: if (root.settingsManager) root.settingsManager.steamIntegration = checked @@ -159,7 +181,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: borderlessCheck + objectName: "checkBorderless" Kirigami.FormData.label: i18nc("@option:check", "Borderless windows") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.sessionRunner ? root.sessionRunner.borderlessWindows : root.borderlessWindows onToggled: { if (root.settingsManager) root.settingsManager.borderlessWindows = checked @@ -224,8 +249,12 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnEditPreset" icon.name: "document-edit" display: Controls.AbstractButton.IconOnly + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Edit preset") + Accessible.onPressAction: clicked() Controls.ToolTip.text: i18nc("@info:tooltip", "Edit preset") Controls.ToolTip.visible: hovered Controls.ToolTip.delay: 1000 @@ -239,9 +268,13 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnRemovePreset" visible: !modelData.isBuiltin icon.name: "edit-delete" display: Controls.AbstractButton.IconOnly + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Remove preset") + Accessible.onPressAction: clicked() Controls.ToolTip.text: i18nc("@info:tooltip", "Remove preset") Controls.ToolTip.visible: hovered Controls.ToolTip.delay: 1000 @@ -256,8 +289,12 @@ Kirigami.ScrollablePage { // Add preset button Controls.Button { + objectName: "btnAddPreset" text: i18nc("@action:button", "Add from Application...") icon.name: "list-add" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: { activePresetManager.scanApplications() addPresetDialog.open() @@ -304,8 +341,12 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnUnignoreDevice" icon.name: "list-remove" display: Controls.AbstractButton.IconOnly + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Stop ignoring this device") + Accessible.onPressAction: clicked() Controls.ToolTip.text: i18nc("@info:tooltip", "Stop ignoring this device") Controls.ToolTip.visible: hovered Controls.ToolTip.delay: 1000 @@ -351,9 +392,13 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnReloadSteam" visible: root.steamConfigManager && root.steamConfigManager.steamDetected text: i18nc("@action:button", "Reload") icon.name: "view-refresh" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: { root.steamConfigManager.loadShortcuts() applicationWindow().showPassiveNotification( @@ -364,7 +409,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: syncShortcutsCheck + objectName: "checkSyncSteamShortcuts" Kirigami.FormData.label: i18nc("@option:check", "Sync shortcuts to players:") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.steamConfigManager ? root.steamConfigManager.syncShortcutsEnabled : false onToggled: { if (root.steamConfigManager) { @@ -416,9 +464,13 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnReloadHeroic" visible: root.heroicConfigManager && root.heroicConfigManager.heroicDetected text: i18nc("@action:button", "Reload") icon.name: "view-refresh" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: { root.heroicConfigManager.loadGames() applicationWindow().showPassiveNotification( @@ -438,7 +490,10 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: heroicSyncShortcutsCheck + objectName: "checkSyncHeroicShortcuts" Kirigami.FormData.label: i18nc("@option:check", "Sync shortcuts to players:") + Accessible.role: Accessible.CheckBox + Accessible.name: Kirigami.FormData.label checked: root.heroicConfigManager ? root.heroicConfigManager.syncShortcutsEnabled : false onToggled: { if (root.heroicConfigManager) { @@ -523,10 +578,14 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnInstallHelper" visible: !root.helperAvailable Kirigami.FormData.label: " " text: i18nc("@action:button", "Install Helper...") icon.name: "run-install" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: installHelperDialog.open() } } @@ -551,8 +610,12 @@ Kirigami.ScrollablePage { } Controls.Button { + objectName: "btnConfigureShortcuts" text: i18nc("@action:button", "Configure...") icon.name: "configure-shortcuts" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: clicked() onClicked: { Qt.openUrlExternally("systemsettings://kcm_keys?search=couchplay") } @@ -576,8 +639,12 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionLearnMore" icon.name: "help-about" text: i18nc("@action:button", "Learn More") + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: triggered() onTriggered: Qt.openUrlExternally("https://github.com/hikaps/couchplay#helper-setup") } ] diff --git a/src/qml/pages/UsersPage.qml b/src/qml/pages/UsersPage.qml index f827757..a87e423 100644 --- a/src/qml/pages/UsersPage.qml +++ b/src/qml/pages/UsersPage.qml @@ -20,11 +20,19 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionRefresh" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Refresh") + Accessible.onPressAction: triggered() icon.name: "view-refresh" text: i18nc("@action:button", "Refresh") onTriggered: userManager?.refresh() }, Kirigami.Action { + objectName: "actionAddUser" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Add User") + Accessible.onPressAction: triggered() icon.name: "list-add" text: i18nc("@action:button", "Add User") onTriggered: { @@ -158,6 +166,10 @@ Kirigami.ScrollablePage { // Delete button Controls.Button { + objectName: "btnDeleteUser" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Delete") + Accessible.onPressAction: clicked() icon.name: "edit-delete" text: i18nc("@action:button", "Delete") enabled: helperClient?.available ?? false @@ -189,8 +201,12 @@ Kirigami.ScrollablePage { text: i18nc("@info:placeholder", "No Gaming Users") explanation: i18nc("@info", "Create dedicated gaming users to enable split-screen multiplayer. Each user will have their own Steam installation and game saves.") - helpfulAction: Kirigami.Action { - icon.name: "list-add-user" + helpfulAction: Kirigami.Action { + objectName: "actionCreateGamingUser" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Create Gaming User") + Accessible.onPressAction: triggered() + icon.name: "list-add-user" text: i18nc("@action:button", "Create Gaming User") enabled: helperClient?.available ?? false onTriggered: { @@ -220,6 +236,10 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { + objectName: "actionLearnMore" + Accessible.role: Accessible.Button + Accessible.name: i18nc("@action:button", "Learn More") + Accessible.onPressAction: triggered() icon.name: "help-about" text: i18nc("@action:button", "Learn More") onTriggered: Qt.openUrlExternally("https://github.com/hikaps/couchplay#helper-setup") @@ -282,6 +302,9 @@ Kirigami.ScrollablePage { // Add User Dialog Kirigami.Dialog { id: addUserDialog + objectName: "dialogAddUser" + Accessible.role: Accessible.Dialog + Accessible.name: i18nc("@title:dialog", "Create Gaming User") title: i18nc("@title:dialog", "Create Gaming User") standardButtons: Kirigami.Dialog.NoButton preferredWidth: Kirigami.Units.gridUnit * 20 @@ -316,6 +339,9 @@ Kirigami.ScrollablePage { Kirigami.FormLayout { Controls.TextField { id: usernameField + objectName: "fieldUsername" + Accessible.role: Accessible.EditableText + Accessible.name: i18nc("@label", "Username") Kirigami.FormData.label: i18nc("@label", "Username:") placeholderText: i18nc("@info:placeholder", "player2") validator: RegularExpressionValidator { @@ -358,6 +384,9 @@ Kirigami.ScrollablePage { // Delete User Confirmation Dialog Kirigami.Dialog { id: deleteUserDialog + objectName: "dialogDeleteUser" + Accessible.role: Accessible.Dialog + Accessible.name: i18nc("@title:dialog", "Delete User") title: i18nc("@title:dialog", "Delete User") standardButtons: Kirigami.Dialog.NoButton preferredWidth: Kirigami.Units.gridUnit * 22 @@ -410,6 +439,9 @@ Kirigami.ScrollablePage { Controls.CheckBox { id: deleteHomeCheckbox + objectName: "checkDeleteHome" + Accessible.role: Accessible.CheckBox + Accessible.name: i18nc("@option:check", "Also delete home directory and all user data") text: i18nc("@option:check", "Also delete home directory and all user data") Layout.fillWidth: true }