feat(update): surface backend update receipt in action popup (#958) (#964)

Adopt GET /api/hermes/update/receipt and append its summary to the
end of the hermes-update action progress popup after it finishes.
Additive only: existing checkHermesUpdate + connection-retry polling
is untouched. 404 (old backend) is handled gracefully.

- add UpdateReceiptResponse data model matching backend schema
- add getUpdateReceipt() to HermesApiService
- ActionProgressController gains trailingLines + pushTrailingLines
- ActionProgressDialog renders trailingLines after the log
- SystemViewModel fetches receipt on action finish and formats it
- extract pure formatUpdateReceiptLines for mockk-free testing
This commit is contained in:
M57 authored and GitHub committed 2026-08-24 01:35:59 +03:00
1 parent 5fd732be9b
commit ece0288ca8
8 files changed
+342 -1

No files matched your search

@@ -189,6 +189,36 @@ class ActionProgressControllerTest {
assertEquals(callsAfterFirstPoll, calls)
}
@Test
fun `pushTrailingLines appends after the log without touching the phase`() {
val controller = controller()
coEvery { mockApi.getActionStatus("hermes-update") } returns
Response.success(
ActionStatusResponse(name = "hermes-update", running = false, exit_code = 0),
)
controller.open()
controller.markStarted("hermes-update")
tick()
assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase)
assertTrue(controller.state.value.trailingLines.isEmpty())
controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5"))
val state = controller.state.value
assertEquals(ActionProgressPhase.SUCCEEDED, state.phase)
assertEquals(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5"), state.trailingLines)
}
@Test
fun `pushTrailingLines ignores empty input`() {
val controller = controller()
controller.open()
controller.pushTrailingLines(emptyList())
assertTrue(controller.state.value.trailingLines.isEmpty())
}
@Test
fun `onFinished is not called when dismissed mid-run`() {
val controller = controller()
@@ -0,0 +1,109 @@
package com.m57.hermescontrol.ui.system
import com.m57.hermescontrol.data.model.UpdateReceipt
import com.m57.hermescontrol.data.model.UpdateReceiptFleetEntry
import com.m57.hermescontrol.data.model.UpdateReceiptResponse
import com.m57.hermescontrol.data.model.UpdateReceiptSummary
import com.m57.hermescontrol.data.model.UpdateReceiptVersion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure-logic tests for [formatUpdateReceiptLines] (issue #958). Mockk cannot
* self-attach its agent in this sandbox, so these avoid the API layer entirely
* and exercise the formatting directly.
*/
class UpdateReceiptFormattingTest {
@Test
fun `null receipt yields empty list`() {
assertTrue(formatUpdateReceiptLines(null).isEmpty())
}
@Test
fun `empty receipt (no summary, no record) yields empty list`() {
assertTrue(formatUpdateReceiptLines(UpdateReceiptResponse()).isEmpty())
}
@Test
fun `full receipt renders outcome version and fleet`() {
val receipt =
UpdateReceiptResponse(
receipt =
UpdateReceipt(
outcome = "success",
preUpdate = UpdateReceiptVersion(version = "0.20.4", sha = "a".repeat(40)),
postUpdate = UpdateReceiptVersion(version = "0.20.5", sha = "b".repeat(40)),
fleet =
listOf(
UpdateReceiptFleetEntry(profile = "default", state = "current"),
UpdateReceiptFleetEntry(profile = "work", state = "stale"),
),
),
summary =
UpdateReceiptSummary(
outcome = "success",
postVersion = "0.20.5",
fleetStates = listOf("current", "stale"),
),
)
val lines = formatUpdateReceiptLines(receipt)
assertEquals(
listOf(
"Update receipt",
"Outcome: success",
"Version: 0.20.4 → 0.20.5",
"Fleet: default: current, work: stale",
),
lines,
)
}
@Test
fun `summary-only receipt falls back to postVersion and fleetStates`() {
val receipt =
UpdateReceiptResponse(
summary =
UpdateReceiptSummary(
outcome = "partial",
postVersion = "0.21.0",
fleetStates = listOf("current"),
),
)
val lines = formatUpdateReceiptLines(receipt)
assertEquals(
listOf(
"Update receipt",
"Outcome: partial",
"Version: → 0.21.0",
"Fleet: current",
),
lines,
)
}
@Test
fun `version falls back to short sha when version is missing`() {
val receipt =
UpdateReceiptResponse(
receipt =
UpdateReceipt(
outcome = "success",
preUpdate = UpdateReceiptVersion(sha = "abcdef1234567890deadbeef"),
postUpdate = UpdateReceiptVersion(sha = "11112222"),
),
)
val lines = formatUpdateReceiptLines(receipt)
assertEquals(
listOf(
"Update receipt",
"Outcome: success",
"Version: abcdef12 → 11112222",
),
lines,
)
}
}