From 7fc3ccebe5ee9a8f16610da353b26c513a467a16 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 01:27:18 -0700 Subject: [PATCH 01/19] feat(a11y): add accessibility identifiers to Main.qml --- src/qml/Main.qml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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: { From d37e63447f5c99c8c3f57b0222f63d0993b01a1f Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 01:28:42 -0700 Subject: [PATCH 02/19] feat(a11y): add accessibility identifiers to all pages --- src/qml/pages/DeviceAssignmentPage.qml | 48 ++++++++++++++++ src/qml/pages/HomePage.qml | 32 +++++++++++ src/qml/pages/ProfilesPage.qml | 27 +++++++++ src/qml/pages/SessionSetupPage.qml | 77 ++++++++++++++++++++++++++ src/qml/pages/SettingsPage.qml | 67 ++++++++++++++++++++++ src/qml/pages/UsersPage.qml | 36 +++++++++++- 6 files changed, 285 insertions(+), 2 deletions(-) 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 } From 836e34ac90209492f261ebfc9a4fd64e6bf9b5b1 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 01:29:02 -0700 Subject: [PATCH 03/19] feat(a11y): add accessibility identifiers to components and dialogs --- src/qml/components/ActionCard.qml | 4 ++++ src/qml/components/PresetSelector.qml | 4 ++++ src/qml/components/SelectableCard.qml | 4 ++++ src/qml/components/dialogs/AddPresetDialog.qml | 10 ++++++++++ src/qml/components/dialogs/DeletePresetDialog.qml | 3 +++ src/qml/components/dialogs/EditPresetDialog.qml | 11 +++++++++++ src/qml/components/dialogs/InstallHelperDialog.qml | 3 +++ src/qml/components/dialogs/ResetSettingsDialog.qml | 3 +++ 8 files changed, 42 insertions(+) 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 From 6306d865a9d16e52fcfa02562fe5b3402a2d53a9 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 01:29:27 -0700 Subject: [PATCH 04/19] feat(e2e): add test infrastructure and smoke tests --- appiumtests/__init__.py | 2 + appiumtests/conftest.py | 167 +++++++++++++++++ appiumtests/helpers/__init__.py | 2 + appiumtests/helpers/base_test.py | 48 +++++ appiumtests/helpers/mock_helper.py | 243 +++++++++++++++++++++++++ appiumtests/helpers/test_users.py | 40 ++++ appiumtests/helpers/virtual_devices.py | 52 ++++++ appiumtests/requirements.txt | 7 + appiumtests/test_home.py | 45 +++++ appiumtests/test_profiles.py | 28 +++ appiumtests/test_session_setup.py | 63 +++++++ appiumtests/test_settings.py | 39 ++++ 12 files changed, 736 insertions(+) create mode 100644 appiumtests/__init__.py create mode 100644 appiumtests/conftest.py create mode 100644 appiumtests/helpers/__init__.py create mode 100644 appiumtests/helpers/base_test.py create mode 100644 appiumtests/helpers/mock_helper.py create mode 100644 appiumtests/helpers/test_users.py create mode 100644 appiumtests/helpers/virtual_devices.py create mode 100644 appiumtests/requirements.txt create mode 100644 appiumtests/test_home.py create mode 100644 appiumtests/test_profiles.py create mode 100644 appiumtests/test_session_setup.py create mode 100644 appiumtests/test_settings.py 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..611aed9 --- /dev/null +++ b/appiumtests/conftest.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +import os +import subprocess +import sys +import time + +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..2922b39 --- /dev/null +++ b/appiumtests/requirements.txt @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 CouchPlay Contributors + +Appium-Python-Client>=3.1.0 +pytest>=8.0.0 +dbus-python>=1.3.2 +evdev>=1.7.1 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_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() From 974acbeaa50d8260199e23a391aa2ca27a659e61 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 01:29:45 -0700 Subject: [PATCH 05/19] feat(e2e): add session lifecycle tests with mock D-Bus helper --- appiumtests/test_devices.py | 48 +++++++++++++++++++++++ appiumtests/test_session.py | 77 +++++++++++++++++++++++++++++++++++++ appiumtests/test_users.py | 37 ++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 appiumtests/test_devices.py create mode 100644 appiumtests/test_session.py create mode 100644 appiumtests/test_users.py 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_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_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") From be69f3df16c39bbc5303f86dc43d63d837ee78c0 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 01:30:44 -0700 Subject: [PATCH 06/19] ci: add e2e test job and update documentation --- .github/workflows/ci.yml | 54 +++++++++++++++++++ AGENTS.md | 11 +++- appiumtests/AGENTS.md | 110 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 appiumtests/AGENTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 320765c..fbb6aee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,57 @@ 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 dependencies + run: | + dnf install -y cmake gcc-c++ git make python3-pip \ + qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qt5compat-devel \ + kf6-kirigami-devel kf6-ki18n-devel kf6-kcoreaddons-devel \ + kf6-kconfig-devel kf6-kiconthemes-devel kf6-qqc2-desktop-style \ + kf6-kglobalaccel-devel extra-cmake-modules \ + pipewire-devel polkit-devel \ + kwayland-devel at-spi2-core \ + python3-dbus systemd-loginctl \ + selenium-webdriver-at-spi + modprobe uinput || true + + - 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: | + mkdir -p ~/.local/share/applications + sed "s|Exec=couchplay|Exec=$(pwd)/build/bin/couchplay|" \ + data/io.github.hikaps.couchplay.desktop \ + > ~/.local/share/applications/io.github.hikaps.couchplay.desktop + update-desktop-database ~/.local/share/applications || true + + - name: Run e2e tests + run: selenium-webdriver-at-spi-run pytest appiumtests/ -v + env: + QT_LINUX_ACCESSIBILITY_ALWAYS_ON: 1 + + - 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 From 40366d970fbf80f882fae46bcb193bbf7f401aea Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 02:09:12 -0700 Subject: [PATCH 07/19] =?UTF-8?q?ci:=20fix=20e2e-tests=20job=20=E2=80=94?= =?UTF-8?q?=20build=20selenium-webdriver-at-spi=20from=20source=20-=20Remo?= =?UTF-8?q?ve=20non-existent=20packages:=20systemd-loginctl=20(not=20a=20r?= =?UTF-8?q?eal=20package),=20=20=20selenium-webdriver-at-spi=20(not=20in?= =?UTF-8?q?=20Fedora=2041=20repos)=20-=20Add=20'Build=20selenium-webdriver?= =?UTF-8?q?-at-spi'=20step:=20clone=20from=20KDE=20invent,=20=20=20patch?= =?UTF-8?q?=20out=20videorecorder=20(Qt6GuiPrivate=20conflict=20on=20F41),?= =?UTF-8?q?=20build=20&=20install=20-=20Add=20all=20required=20build/runti?= =?UTF-8?q?me=20deps:=20ruby,=20qt6-qtwayland-devel,=20=20=20kf6-kwindowsy?= =?UTF-8?q?stem-devel,=20kwayland-devel,=20kpipewire-devel,=20=20=20plasma?= =?UTF-8?q?-wayland-protocols-devel,=20python3-flask,=20python3-pyatspi,?= =?UTF-8?q?=20=20=20python3-gobject-base,=20gobject-introspection-devel,?= =?UTF-8?q?=20dbus-x11,=20etc.=20-=20Move=20dbus-python=20and=20evdev=20fr?= =?UTF-8?q?om=20pip=20requirements=20to=20dnf=20packages=20=20=20(pip=20bu?= =?UTF-8?q?ilds=20fail=20on=20Fedora,=20system=20packages=20work=20correct?= =?UTF-8?q?ly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++------- appiumtests/requirements.txt | 2 -- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbb6aee..ee8b716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,19 +62,36 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install dependencies + - name: Install system dependencies run: | - dnf install -y cmake gcc-c++ git make python3-pip \ - qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qt5compat-devel \ + dnf install -y --skip-unavailable \ + cmake gcc-c++ git make ruby \ + 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 extra-cmake-modules \ + kf6-kglobalaccel-devel kf6-kwindowsystem-devel \ + extra-cmake-modules \ pipewire-devel polkit-devel \ - kwayland-devel at-spi2-core \ - python3-dbus systemd-loginctl \ - selenium-webdriver-at-spi + kwayland-devel kpipewire-devel plasma-wayland-protocols-devel \ + at-spi2-core at-spi2-atk-devel \ + python3-pip python3-dbus python3-evdev \ + python3-flask python3-pyatspi python3-gobject-base \ + gobject-introspection-devel dbus-x11 \ + xcb-util-devel modprobe uinput || true + - 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 diff --git a/appiumtests/requirements.txt b/appiumtests/requirements.txt index 2922b39..e447c65 100644 --- a/appiumtests/requirements.txt +++ b/appiumtests/requirements.txt @@ -3,5 +3,3 @@ Appium-Python-Client>=3.1.0 pytest>=8.0.0 -dbus-python>=1.3.2 -evdev>=1.7.1 From ebe3930acc8ff4cf6a283903c5c86e04ea99f849 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 10:51:32 -0700 Subject: [PATCH 08/19] =?UTF-8?q?ci:=20add=20wayland-devel=20for=20seleniu?= =?UTF-8?q?m-webdriver-at-spi=20build=20CMake=20failed=20to=20find=20Wayla?= =?UTF-8?q?nd=5FClient/Wayland=5FServer=20because=20the=20base=20wayland-d?= =?UTF-8?q?evel=20package=20was=20missing=20=E2=80=94=20qt6-qtwayland-deve?= =?UTF-8?q?l=20only=20pulls=20in=20the=20runtime=20library,=20not=20the=20?= =?UTF-8?q?dev=20headers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee8b716..d2999ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: extra-cmake-modules \ pipewire-devel polkit-devel \ kwayland-devel kpipewire-devel plasma-wayland-protocols-devel \ + wayland-devel wayland-protocols-devel \ at-spi2-core at-spi2-atk-devel \ python3-pip python3-dbus python3-evdev \ python3-flask python3-pyatspi python3-gobject-base \ From ea41b73718a48427e5ada8f14c064c56e73579f9 Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 11:12:47 -0700 Subject: [PATCH 09/19] ci: create /usr/bin/pip3 wrapper for selenium-webdriver-at-spi-run The Ruby runner script expects pip3 in PATH. Fedora's python3-pip only provides 'python3 -m pip', not /usr/bin/pip3. Create a shell wrapper. --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2999ad..dec90cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,11 @@ jobs: cmake --install /tmp/selenium-webdriver-at-spi/build - name: Install Python dependencies - run: pip install -r appiumtests/requirements.txt + run: | + # selenium-webdriver-at-spi-run requires /usr/bin/pip3 in PATH + printf '#!/bin/sh\nexec python3 -m pip "$@"\n' > /usr/bin/pip3 + chmod +x /usr/bin/pip3 + pip install -r appiumtests/requirements.txt - name: Configure Git safe directory run: git config --global --add safe.directory /__w/couchplay/couchplay From 3c7d832985dedac80fefec015da1021d695d178d Mon Sep 17 00:00:00 2001 From: hikaps Date: Sun, 22 Mar 2026 12:06:19 -0700 Subject: [PATCH 10/19] ci: move pip3 wrapper to system deps step The wrapper must exist before selenium-webdriver-at-spi-run starts, which happens in the test step. Moving it to the system deps step ensures it persists across all subsequent steps. --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec90cc..d51942d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,10 @@ jobs: 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: | @@ -94,11 +98,7 @@ jobs: cmake --install /tmp/selenium-webdriver-at-spi/build - name: Install Python dependencies - run: | - # selenium-webdriver-at-spi-run requires /usr/bin/pip3 in PATH - printf '#!/bin/sh\nexec python3 -m pip "$@"\n' > /usr/bin/pip3 - chmod +x /usr/bin/pip3 - pip install -r appiumtests/requirements.txt + run: pip install -r appiumtests/requirements.txt - name: Configure Git safe directory run: git config --global --add safe.directory /__w/couchplay/couchplay From 2c4fdfedf729ec9a70c50e8fa2aa5e7a1f317794 Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 11:06:38 -0700 Subject: [PATCH 11/19] =?UTF-8?q?ci:=20add=20'which'=20package=20=E2=80=94?= =?UTF-8?q?=20selenium-webdriver-at-spi-run=20needs=20it=20The=20runner=20?= =?UTF-8?q?script=20uses=20system('which',=20'pip3')=20to=20check=20for=20?= =?UTF-8?q?pip3=20in=20PATH.=20Without=20the=20'which'=20binary=20installe?= =?UTF-8?q?d,=20this=20check=20always=20fails=20even=20when=20/usr/bin/pip?= =?UTF-8?q?3=20exists.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d51942d..eed5bed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: - name: Install system dependencies run: | dnf install -y --skip-unavailable \ - cmake gcc-c++ git make ruby \ + 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 \ From 944f968c5e8d543508722db7ad4ccf386a418e0b Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 11:22:54 -0700 Subject: [PATCH 12/19] =?UTF-8?q?ci:=20set=20XDG=5FRUNTIME=5FDIR=20for=20s?= =?UTF-8?q?elenium-webdriver-at-spi-run=20The=20runner=20crashes=20with=20?= =?UTF-8?q?'no=20implicit=20conversion=20of=20nil=20into=20String'=20when?= =?UTF-8?q?=20XDG=5FRUNTIME=5FDIR=20is=20unset.=20Docker=20containers=20do?= =?UTF-8?q?n't=20have=20this=20env=20var=20=E2=80=94=20set=20it=20to=20a?= =?UTF-8?q?=20writable=20tmp=20path.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eed5bed..6105b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,7 @@ jobs: run: selenium-webdriver-at-spi-run pytest appiumtests/ -v env: QT_LINUX_ACCESSIBILITY_ALWAYS_ON: 1 + XDG_RUNTIME_DIR: /tmp/runtime-runner - name: Upload failure screenshots if: failure() From 9dedc658bd30fc25872e586818319f7ebc974dbf Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 13:14:43 -0700 Subject: [PATCH 13/19] ci: add kwin-wayland for virtual compositor selenium-webdriver-at-spi-run launches kwin_wayland --virtual to host the app under test. Without it: No such file or directory - kwin_wayland. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6105b52..ffb3d59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: 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 \ From ab5d265a3c6189d3eb33ada1d2d1c10069c3169f Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 13:53:04 -0700 Subject: [PATCH 14/19] ci: skip kwin_wayland, use offscreen platform in CI GitHub Actions containers lack DRM/GPU capabilities needed by kwin_wayland --virtual (EPERM on exec). Set TEST_WITH_KWIN_WAYLAND=0 to skip the compositor and QT_QPA_PLATFORM=offscreen for headless rendering. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffb3d59..c7a19d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,8 @@ jobs: 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() From 9893df1543c940cd02ef08d77d6707fbf3193eab Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 14:11:29 -0700 Subject: [PATCH 15/19] ci: add python3-numpy for selenium-webdriver-at-spi driver The Flask driver (selenium-webdriver-at-spi.py) imports numpy at startup. Without it: ModuleNotFoundError: No module named 'numpy'. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7a19d0..e2e1de0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: kwin-wayland \ wayland-devel wayland-protocols-devel \ at-spi2-core at-spi2-atk-devel \ - python3-pip python3-dbus python3-evdev \ + python3-pip python3-dbus python3-evdev python3-numpy \ python3-flask python3-pyatspi python3-gobject-base \ gobject-introspection-devel dbus-x11 \ xcb-util-devel From cfe3ed0ac38d2b592d71a736723628480b0661d3 Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 16:20:54 -0700 Subject: [PATCH 16/19] ci: set pytest rootdir to fix module resolution pytest was running from the project root, so 'from helpers.base_test import BaseTest' failed with ModuleNotFoundError. --rootdir=appiumtests makes pytest resolve imports relative to the test directory. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e1de0..cdf0ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: update-desktop-database ~/.local/share/applications || true - name: Run e2e tests - run: selenium-webdriver-at-spi-run pytest appiumtests/ -v + run: selenium-webdriver-at-spi-run pytest appiumtests/ -v --rootdir=appiumtests env: QT_LINUX_ACCESSIBILITY_ALWAYS_ON: 1 XDG_RUNTIME_DIR: /tmp/runtime-runner From b1d1477aaca25c4441bcea133dd2e4ad7942d6a0 Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 16:39:27 -0700 Subject: [PATCH 17/19] fix: add appiumtests/ to sys.path for module resolution pytest runs from the project root (via selenium-webdriver-at-spi-run), so 'from helpers.base_test import BaseTest' failed. Add conftest.py to sys.path so helpers/ resolves correctly. --- .github/workflows/ci.yml | 2 +- appiumtests/conftest.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdf0ecf..e2e1de0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: update-desktop-database ~/.local/share/applications || true - name: Run e2e tests - run: selenium-webdriver-at-spi-run pytest appiumtests/ -v --rootdir=appiumtests + run: selenium-webdriver-at-spi-run pytest appiumtests/ -v env: QT_LINUX_ACCESSIBILITY_ALWAYS_ON: 1 XDG_RUNTIME_DIR: /tmp/runtime-runner diff --git a/appiumtests/conftest.py b/appiumtests/conftest.py index 611aed9..9bdcc42 100644 --- a/appiumtests/conftest.py +++ b/appiumtests/conftest.py @@ -6,6 +6,8 @@ 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 48a1cb8351243f625e7ba5ad05e268af06775918 Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 16:59:01 -0700 Subject: [PATCH 18/19] ci: install desktop file to /usr/share/applications selenium-webdriver-at-spi-run overrides XDG_DATA_HOME to a temp dir, so ~/.local/share/applications/ is invisible. Install to the system location /usr/share/applications/ and symlink binary to /usr/local/bin/. --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e1de0..05640ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,11 +112,11 @@ jobs: - name: Install desktop file run: | - mkdir -p ~/.local/share/applications - sed "s|Exec=couchplay|Exec=$(pwd)/build/bin/couchplay|" \ + ln -sf "$(pwd)/build/bin/couchplay" /usr/local/bin/couchplay + sed "s|Exec=couchplay|Exec=couchplay|" \ data/io.github.hikaps.couchplay.desktop \ - > ~/.local/share/applications/io.github.hikaps.couchplay.desktop - update-desktop-database ~/.local/share/applications || true + > /usr/share/applications/io.github.hikaps.couchplay.desktop + update-desktop-database /usr/share/applications || true - name: Run e2e tests run: selenium-webdriver-at-spi-run pytest appiumtests/ -v From 9c0138715304897670957213c5179ce972a02246 Mon Sep 17 00:00:00 2001 From: hikaps Date: Mon, 23 Mar 2026 17:28:42 -0700 Subject: [PATCH 19/19] ci: symlink app binary as desktop ID name GAppInfo tries to execute 'io.github.hikaps.couchplay' (the desktop file ID) as a binary when it can't find the desktop file. Add a symlink with that name to /usr/local/bin/ so the binary is findable by GLib's app lookup. --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05640ed..f6ba7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,10 +113,9 @@ jobs: - name: Install desktop file run: | ln -sf "$(pwd)/build/bin/couchplay" /usr/local/bin/couchplay - sed "s|Exec=couchplay|Exec=couchplay|" \ - data/io.github.hikaps.couchplay.desktop \ - > /usr/share/applications/io.github.hikaps.couchplay.desktop - update-desktop-database /usr/share/applications || true + 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