Fix Home action completion and test isolation

This commit is contained in:
Gabriel Brown
2026-08-17 16:49:50 -04:00
parent c2b19f7545
commit a325ec807e
3 changed files with 217 additions and 42 deletions
@@ -21,12 +21,17 @@ Singleton {
property string lastError: "" property string lastError: ""
property bool fixtureMode: false property bool fixtureMode: false
property var fixtureFavorites: [] property var fixtureFavorites: []
property string fixtureProcessMode: ""
property var actionQueue: [] property var actionQueue: []
property var activeAction: null property var activeAction: null
property var busyEntityIds: [] property var busyEntityIds: []
property var pendingBrightness: ({}) property var pendingBrightness: ({})
property var entityErrors: ({}) 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 var visibleEntities: root.selectedEntities.slice(0, 4)
readonly property int discoveredCount: root.catalog.length readonly property int discoveredCount: root.catalog.length
@@ -177,7 +182,7 @@ Singleton {
root.pendingBrightness = nextPending; root.pendingBrightness = nextPending;
} }
if (root.fixtureMode) { if (root.fixtureMode && root.fixtureProcessMode === "") {
root.applyFixtureAction(action); root.applyFixtureAction(action);
root.finishActionState(action.entityId); root.finishActionState(action.entityId);
return; return;
@@ -192,27 +197,73 @@ Singleton {
return; return;
root.activeAction = root.actionQueue[0]; root.activeAction = root.actionQueue[0];
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" actionProc.command = root.activeAction.kind === "brightness"
? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)] ? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
: [root.helperPath, "toggle", root.activeAction.entityId]; : [root.helperPath, "toggle", root.activeAction.entityId];
}
actionProc.running = true; 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) if (root.activeAction === null)
return; return;
const completedAction = root.activeAction; const completedAction = root.activeAction;
let ok = false; let ok = false;
let errorCode = "action-failed"; let errorCode = text === "" ? "action-failed" : "invalid-response";
try { try {
const result = JSON.parse(text); const result = JSON.parse(text);
ok = result.ok === true; ok = exitCode === 0 && result.ok === true;
errorCode = String(result.error || errorCode); errorCode = String(result.error || "action-failed");
} catch (error) { } 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.actionQueue = root.actionQueue.slice(1);
root.activeAction = null; root.activeAction = null;
root.finishActionState(completedAction.entityId); root.finishActionState(completedAction.entityId);
@@ -220,12 +271,15 @@ Singleton {
const nextErrors = Object.assign({}, root.entityErrors); const nextErrors = Object.assign({}, root.entityErrors);
if (ok) { if (ok) {
delete nextErrors[completedAction.entityId]; delete nextErrors[completedAction.entityId];
if (root.fixtureMode && root.fixtureProcessMode !== "")
root.applyFixtureAction(completedAction);
else
refreshDelay.restart(); refreshDelay.restart();
} else { } else {
nextErrors[completedAction.entityId] = errorCode; nextErrors[completedAction.entityId] = errorCode;
} }
root.entityErrors = nextErrors; root.entityErrors = nextErrors;
root.startNextAction(); queueAdvanceTimer.restart();
} }
function finishActionState(entityId: string): void { function finishActionState(entityId: string): void {
@@ -283,19 +337,29 @@ Singleton {
} }
function resetActionState(): void { function resetActionState(): void {
actionCompletionTimer.stop();
queueAdvanceTimer.stop();
root.actionQueue = []; root.actionQueue = [];
root.activeAction = null; root.activeAction = null;
root.busyEntityIds = []; root.busyEntityIds = [];
root.pendingBrightness = {}; root.pendingBrightness = {};
root.entityErrors = {}; root.entityErrors = {};
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
} }
function applyFixture(name: string): void { 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; return;
root.fixtureMode = true; root.fixtureMode = true;
root.resetActionState(); root.resetActionState();
root.fixtureProcessMode = name === "process-actions"
? "success"
: (name === "process-no-output" ? "no-output" : "");
if (name === "unavailable") { if (name === "unavailable") {
root.catalog = []; root.catalog = [];
root.fixtureFavorites = []; root.fixtureFavorites = [];
@@ -326,6 +390,7 @@ Singleton {
function clearFixture(): void { function clearFixture(): void {
root.fixtureMode = false; root.fixtureMode = false;
root.fixtureProcessMode = "";
root.phase = "loading"; root.phase = "loading";
root.catalog = []; root.catalog = [];
root.selectedEntities = []; root.selectedEntities = [];
@@ -347,9 +412,27 @@ Singleton {
Process { Process {
id: actionProc id: actionProc
stdout: StdioCollector { 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 { Timer {
+1
View File
@@ -270,6 +270,7 @@ ShellRoot {
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id), selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
entities: HomeAssistant.selectedEntities, entities: HomeAssistant.selectedEntities,
stale: HomeAssistant.stale, stale: HomeAssistant.stale,
busy: HomeAssistant.busyEntityIds.length > 0,
busyEntityIds: HomeAssistant.busyEntityIds, busyEntityIds: HomeAssistant.busyEntityIds,
pendingBrightness: HomeAssistant.pendingBrightness, pendingBrightness: HomeAssistant.pendingBrightness,
entityErrors: HomeAssistant.entityErrors, entityErrors: HomeAssistant.entityErrors,
@@ -2,25 +2,84 @@
set -euo pipefail 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() { fail() {
printf 'Control Center services contract: %s\n' "$1" >&2 printf 'Control Center services contract: %s\n' "$1" >&2
exit 1 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() { cleanup() {
qs ipc call kdeconnect reset >/dev/null 2>&1 || true qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true
qs ipc call home-assistant reset >/dev/null 2>&1 || true qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true
qs ipc call status-events reset >/dev/null 2>&1 || true qs_for_test 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 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 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' || 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' || 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 ' jq -e '
.fixture == true and .fixture == true and
.available == true and .available == true and
@@ -28,35 +87,35 @@ jq -e '
.actionCount == 4 and .actionCount == 4 and
.transferActive == false and .transferActive == false and
.ongoingCount == 0 .ongoingCount == 0
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \ ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|| fail 'reachable phone fixture is malformed' || 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 ' jq -e '
.fixture == true and .fixture == true and
.available == true and .available == true and
.reachable == false and .reachable == false and
.pairedCount == 1 and .pairedCount == 1 and
.ongoingCount == 0 .ongoingCount == 0
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \ ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|| fail 'offline phone fixture is malformed' || 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 ' jq -e '
.fixture == true and .fixture == true and
.transferActive == true and .transferActive == true and
.transferFileName == "Fixture document.pdf" and .transferFileName == "Fixture document.pdf" and
.ongoingCount == 1 .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' || 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' \ 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' || fail 'phone transfer did not leave Ongoing'
qs ipc call home-assistant fixture ready >/dev/null qs_for_test ipc call home-assistant fixture ready >/dev/null
home_ready="$(qs ipc call home-assistant status)" home_ready="$(qs_for_test ipc call home-assistant status)"
jq -e ' jq -e '
.fixture == true and .fixture == true and
.phase == "ready" and .phase == "ready" and
@@ -76,27 +135,57 @@ jq -e '
"Bedroom" "Bedroom"
] and ] and
.stale == false and .stale == false and
.busy == false and
.lastError == "" .lastError == ""
' <<<"$home_ready" >/dev/null \ ' <<<"$home_ready" >/dev/null \
|| fail 'ready Home fixture is malformed' || 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 ' jq -e '
.entities[] | .entities[] |
select(.id == "light.fixture_living") | select(.id == "light.fixture_living") |
.active == true and .state == "on" and .brightnessPct == 64 .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' || 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 ' jq -e '
.entities[] | .entities[] |
select(.id == "light.fixture_living") | select(.id == "light.fixture_living") |
.active == false and .state == "off" .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' || 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 ' jq -e '
.fixture == true and .fixture == true and
.phase == "ready" and .phase == "ready" and
@@ -111,10 +200,10 @@ jq -e '
.active == false and .active == false and
.dimmable == false and .dimmable == false and
.brightnessPct == 0) .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' || 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 ' jq -e '
.fixture == true and .fixture == true and
.phase == "ready" and .phase == "ready" and
@@ -122,10 +211,10 @@ jq -e '
.entityErrors["light.fixture_kitchen"] == "request-failed" and .entityErrors["light.fixture_kitchen"] == "request-failed" and
(.entityErrors["light.fixture_hall"] // "") == "" and (.entityErrors["light.fixture_hall"] // "") == "" and
.lastError == "" .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' || 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 ' jq -e '
.fixture == true and .fixture == true and
.phase == "degraded" and .phase == "degraded" and
@@ -134,10 +223,10 @@ jq -e '
.visibleCount == 4 and .visibleCount == 4 and
.stale == true and .stale == true and
.lastError == "unreachable" .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' || 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 ' jq -e '
.fixture == true and .fixture == true and
.phase == "unavailable" and .phase == "unavailable" and
@@ -145,9 +234,11 @@ jq -e '
.configuredCount == 0 and .configuredCount == 0 and
.visibleCount == 0 and .visibleCount == 0 and
.lastError == "not-configured" .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' || fail 'unavailable Home fixture is malformed'
trap - EXIT trap - EXIT
cleanup cleanup
[[ ! -e "$state_home" ]] \
|| fail 'temporary Home preferences state was not removed after shell exit'
printf 'Control Center services contract: PASS\n' printf 'Control Center services contract: PASS\n'