From a325ec807e564ac80f5be33aca3029c45fc4fb6c Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 17 Aug 2026 16:49:50 -0400 Subject: [PATCH] Fix Home action completion and test isolation --- .../dot/quickshell/services/HomeAssistant.qml | 111 +++++++++++-- config/dot/quickshell/shell.qml | 1 + .../control-center-services-contract.sh | 147 ++++++++++++++---- 3 files changed, 217 insertions(+), 42 deletions(-) diff --git a/config/dot/quickshell/services/HomeAssistant.qml b/config/dot/quickshell/services/HomeAssistant.qml index df2a7cf..0213e7d 100644 --- a/config/dot/quickshell/services/HomeAssistant.qml +++ b/config/dot/quickshell/services/HomeAssistant.qml @@ -21,12 +21,17 @@ Singleton { property string lastError: "" property bool fixtureMode: false property var fixtureFavorites: [] + property string fixtureProcessMode: "" property var actionQueue: [] property var activeAction: null property var busyEntityIds: [] property var pendingBrightness: ({}) property var entityErrors: ({}) + property string actionResponseText: "" + property bool actionStreamFinished: false + property bool actionExited: false + property int actionExitCode: 0 readonly property var visibleEntities: root.selectedEntities.slice(0, 4) readonly property int discoveredCount: root.catalog.length @@ -177,7 +182,7 @@ Singleton { root.pendingBrightness = nextPending; } - if (root.fixtureMode) { + if (root.fixtureMode && root.fixtureProcessMode === "") { root.applyFixtureAction(action); root.finishActionState(action.entityId); return; @@ -192,27 +197,73 @@ Singleton { return; root.activeAction = root.actionQueue[0]; - actionProc.command = root.activeAction.kind === "brightness" - ? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)] - : [root.helperPath, "toggle", root.activeAction.entityId]; + root.actionResponseText = ""; + root.actionStreamFinished = false; + root.actionExited = false; + root.actionExitCode = 0; + if (root.fixtureProcessMode !== "") { + actionProc.command = root.fixtureActionCommand(root.activeAction); + } else { + actionProc.command = root.activeAction.kind === "brightness" + ? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)] + : [root.helperPath, "toggle", root.activeAction.entityId]; + } actionProc.running = true; } - function consumeAction(text: string): void { + function fixtureActionCommand(action: var): var { + if (root.fixtureProcessMode === "no-output" + && action.entityId === "light.fixture_kitchen") { + return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"]; + } + return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"]; + } + + function handleActionStreamFinished(text: string): void { + if (root.activeAction === null || root.actionStreamFinished) + return; + root.actionResponseText = text; + root.actionStreamFinished = true; + actionCompletionTimer.restart(); + } + + function handleActionExited(exitCode: int): void { + if (root.activeAction === null || root.actionExited) + return; + root.actionExited = true; + root.actionExitCode = exitCode; + actionCompletionTimer.restart(); + } + + function tryCompleteAction(): void { + if (root.activeAction === null) + return; + if (actionProc.running) { + actionCompletionTimer.restart(); + return; + } + if (!root.actionExited || !root.actionStreamFinished) + return; + root.consumeAction(root.actionResponseText, root.actionExitCode); + } + + function consumeAction(text: string, exitCode: int): void { if (root.activeAction === null) return; const completedAction = root.activeAction; let ok = false; - let errorCode = "action-failed"; + let errorCode = text === "" ? "action-failed" : "invalid-response"; try { const result = JSON.parse(text); - ok = result.ok === true; - errorCode = String(result.error || errorCode); + ok = exitCode === 0 && result.ok === true; + errorCode = String(result.error || "action-failed"); } catch (error) { - errorCode = "invalid-response"; + // Empty output from a failed process is an action failure, while + // malformed non-empty output remains an invalid response. } + actionCompletionTimer.stop(); root.actionQueue = root.actionQueue.slice(1); root.activeAction = null; root.finishActionState(completedAction.entityId); @@ -220,12 +271,15 @@ Singleton { const nextErrors = Object.assign({}, root.entityErrors); if (ok) { delete nextErrors[completedAction.entityId]; - refreshDelay.restart(); + if (root.fixtureMode && root.fixtureProcessMode !== "") + root.applyFixtureAction(completedAction); + else + refreshDelay.restart(); } else { nextErrors[completedAction.entityId] = errorCode; } root.entityErrors = nextErrors; - root.startNextAction(); + queueAdvanceTimer.restart(); } function finishActionState(entityId: string): void { @@ -283,19 +337,29 @@ Singleton { } function resetActionState(): void { + actionCompletionTimer.stop(); + queueAdvanceTimer.stop(); root.actionQueue = []; root.activeAction = null; root.busyEntityIds = []; root.pendingBrightness = {}; root.entityErrors = {}; + root.actionResponseText = ""; + root.actionStreamFinished = false; + root.actionExited = false; + root.actionExitCode = 0; } function applyFixture(name: string): void { - if (["ready", "stale", "unavailable", "missing-selected", "action-error"].indexOf(name) < 0) + if (["ready", "stale", "unavailable", "missing-selected", "action-error", + "process-actions", "process-no-output"].indexOf(name) < 0) return; root.fixtureMode = true; root.resetActionState(); + root.fixtureProcessMode = name === "process-actions" + ? "success" + : (name === "process-no-output" ? "no-output" : ""); if (name === "unavailable") { root.catalog = []; root.fixtureFavorites = []; @@ -326,6 +390,7 @@ Singleton { function clearFixture(): void { root.fixtureMode = false; + root.fixtureProcessMode = ""; root.phase = "loading"; root.catalog = []; root.selectedEntities = []; @@ -347,9 +412,27 @@ Singleton { Process { id: actionProc stdout: StdioCollector { - id: actionOutput + onStreamFinished: root.handleActionStreamFinished(this.text) + } + onExited: (code, status) => root.handleActionExited(code) + } + + Timer { + id: actionCompletionTimer + interval: 0 + onTriggered: root.tryCompleteAction() + } + + Timer { + id: queueAdvanceTimer + interval: 0 + onTriggered: { + if (actionProc.running) { + queueAdvanceTimer.restart(); + return; + } + root.startNextAction(); } - onExited: (code, status) => root.consumeAction(actionOutput.text) } Timer { diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 2e7007b..2b1968a 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -270,6 +270,7 @@ ShellRoot { selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id), entities: HomeAssistant.selectedEntities, stale: HomeAssistant.stale, + busy: HomeAssistant.busyEntityIds.length > 0, busyEntityIds: HomeAssistant.busyEntityIds, pendingBrightness: HomeAssistant.pendingBrightness, entityErrors: HomeAssistant.entityErrors, diff --git a/tests/quickshell/control-center-services-contract.sh b/tests/quickshell/control-center-services-contract.sh index 6614589..00ecd9a 100755 --- a/tests/quickshell/control-center-services-contract.sh +++ b/tests/quickshell/control-center-services-contract.sh @@ -2,25 +2,84 @@ set -euo pipefail +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +config_path="$repo_dir/config/dot/quickshell" +state_home="$(mktemp -d /tmp/panama-control-center-state.XXXXXX)" +shell_log="$state_home/quickshell.log" + fail() { printf 'Control Center services contract: %s\n' "$1" >&2 exit 1 } +qs_for_test() { + QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ + qs -p "$config_path" "$@" +} + +stop_test_shell() { + qs_for_test kill >/dev/null 2>&1 || true + for _ in $(seq 1 80); do + if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ + && ! qs_for_test ipc show >/dev/null 2>&1; then + return 0 + fi + sleep 0.1 + done + return 1 +} + cleanup() { - qs ipc call kdeconnect reset >/dev/null 2>&1 || true - qs ipc call home-assistant reset >/dev/null 2>&1 || true - qs ipc call status-events reset >/dev/null 2>&1 || true - qs ipc call quicksettings close >/dev/null 2>&1 || true + qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true + qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true + qs_for_test ipc call status-events reset >/dev/null 2>&1 || true + qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true + if stop_test_shell; then + rm -rf "$state_home" + else + printf 'Control Center services contract: branch shell did not stop; retained %s\n' \ + "$state_home" >&2 + fi } trap cleanup EXIT -qs ipc show | rg -q '^target kdeconnect$' \ +start_test_shell() { + stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' + qs_for_test --daemonize >"$shell_log" 2>&1 + for _ in $(seq 1 80); do + if qs_for_test ipc show 2>/dev/null | rg '^target home-assistant$' >/dev/null; then + return + fi + sleep 0.1 + done + sed -n '1,200p' "$shell_log" >&2 + fail 'isolated branch shell did not start' +} + +wait_for_home_status() { + local filter="$1" + local message="$2" + local status="" + + for _ in $(seq 1 80); do + status="$(qs_for_test ipc call home-assistant status)" + if jq -e "$filter" <<<"$status" >/dev/null; then + return + fi + sleep 0.1 + done + printf 'Last Home status: %s\n' "$status" >&2 + fail "$message" +} + +start_test_shell + +qs_for_test ipc show | rg '^target kdeconnect$' >/dev/null \ || fail 'KDE Connect IPC target is missing' -qs ipc show | rg -q '^target home-assistant$' \ +qs_for_test ipc show | rg '^target home-assistant$' >/dev/null \ || fail 'Home Assistant IPC target is missing' -qs ipc call kdeconnect fixture reachable >/dev/null +qs_for_test ipc call kdeconnect fixture reachable >/dev/null jq -e ' .fixture == true and .available == true and @@ -28,35 +87,35 @@ jq -e ' .actionCount == 4 and .transferActive == false and .ongoingCount == 0 -' <<<"$(qs ipc call kdeconnect status)" >/dev/null \ +' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'reachable phone fixture is malformed' -qs ipc call kdeconnect fixture offline >/dev/null +qs_for_test ipc call kdeconnect fixture offline >/dev/null jq -e ' .fixture == true and .available == true and .reachable == false and .pairedCount == 1 and .ongoingCount == 0 -' <<<"$(qs ipc call kdeconnect status)" >/dev/null \ +' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'offline phone fixture is malformed' -qs ipc call kdeconnect fixture transfer >/dev/null +qs_for_test ipc call kdeconnect fixture transfer >/dev/null jq -e ' .fixture == true and .transferActive == true and .transferFileName == "Fixture document.pdf" and .ongoingCount == 1 -' <<<"$(qs ipc call kdeconnect status)" >/dev/null \ +' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'phone transfer did not enter Ongoing' -qs ipc call kdeconnect cancel >/dev/null +qs_for_test ipc call kdeconnect cancel >/dev/null jq -e '.transferActive == false and .ongoingCount == 0' \ - <<<"$(qs ipc call kdeconnect status)" >/dev/null \ + <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'phone transfer did not leave Ongoing' -qs ipc call home-assistant fixture ready >/dev/null -home_ready="$(qs ipc call home-assistant status)" +qs_for_test ipc call home-assistant fixture ready >/dev/null +home_ready="$(qs_for_test ipc call home-assistant status)" jq -e ' .fixture == true and .phase == "ready" and @@ -76,27 +135,57 @@ jq -e ' "Bedroom" ] and .stale == false and + .busy == false and .lastError == "" ' <<<"$home_ready" >/dev/null \ || fail 'ready Home fixture is malformed' -qs ipc call home-assistant brightness light.fixture_living 64 >/dev/null +qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null jq -e ' .entities[] | select(.id == "light.fixture_living") | .active == true and .state == "on" and .brightnessPct == 64 -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home brightness fixture did not update local catalog state' -qs ipc call home-assistant toggle light.fixture_living >/dev/null +qs_for_test ipc call home-assistant toggle light.fixture_living >/dev/null jq -e ' .entities[] | select(.id == "light.fixture_living") | .active == false and .state == "off" -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home toggle fixture did not update local catalog state' -qs ipc call home-assistant fixture missing-selected >/dev/null +qs_for_test ipc call home-assistant fixture process-actions >/dev/null +qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null +qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null +jq -e ' + .busy == true and + .busyEntityIds == ["light.fixture_living", "light.fixture_hall"] and + .pendingBrightness["light.fixture_living"] == 64 +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ + || fail 'process-backed Home actions did not remain per-entity busy' +wait_for_home_status ' + .busy == false and + .busyEntityIds == [] and + .pendingBrightness == {} and + (.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64) and + (.entities[] | select(.id == "light.fixture_hall") | .active == true) +' 'process-backed Home actions did not complete in queue order' + +qs_for_test ipc call home-assistant fixture process-no-output >/dev/null +qs_for_test ipc call home-assistant toggle light.fixture_kitchen >/dev/null +qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null +wait_for_home_status ' + .phase == "ready" and + .busy == false and + .busyEntityIds == [] and + .entityErrors["light.fixture_kitchen"] == "action-failed" and + (.entityErrors["light.fixture_hall"] // "") == "" and + (.entities[] | select(.id == "light.fixture_hall") | .active == true) +' 'nonzero no-output Home action did not fail safely and continue the queue' + +qs_for_test ipc call home-assistant fixture missing-selected >/dev/null jq -e ' .fixture == true and .phase == "ready" and @@ -111,10 +200,10 @@ jq -e ' .active == false and .dimmable == false and .brightnessPct == 0) -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'missing Home selection was not retained as unavailable' -qs ipc call home-assistant fixture action-error >/dev/null +qs_for_test ipc call home-assistant fixture action-error >/dev/null jq -e ' .fixture == true and .phase == "ready" and @@ -122,10 +211,10 @@ jq -e ' .entityErrors["light.fixture_kitchen"] == "request-failed" and (.entityErrors["light.fixture_hall"] // "") == "" and .lastError == "" -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home action error was not isolated to one entity' -qs ipc call home-assistant fixture stale >/dev/null +qs_for_test ipc call home-assistant fixture stale >/dev/null jq -e ' .fixture == true and .phase == "degraded" and @@ -134,10 +223,10 @@ jq -e ' .visibleCount == 4 and .stale == true and .lastError == "unreachable" -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'stale Home fixture did not retain entities' -qs ipc call home-assistant fixture unavailable >/dev/null +qs_for_test ipc call home-assistant fixture unavailable >/dev/null jq -e ' .fixture == true and .phase == "unavailable" and @@ -145,9 +234,11 @@ jq -e ' .configuredCount == 0 and .visibleCount == 0 and .lastError == "not-configured" -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'unavailable Home fixture is malformed' trap - EXIT cleanup +[[ ! -e "$state_home" ]] \ + || fail 'temporary Home preferences state was not removed after shell exit' printf 'Control Center services contract: PASS\n'