fix(ui): a warming-up app reads as "starting", not "App not reachable"
Demo images / Build & push demo images (push) Failing after 3m45s

A container that is up but hasn't answered its probe yet rendered the hard
failure overlay — padlock icon, "App not reachable", "the container is
stopped". Both bitcoind (RPC -28 for its whole warm-up) and lnd (unreachable
until the wallet unlocks) sit in that window on every boot, so the node
looked broken while it was working normally.

The retry machinery was already correct: 6 × 10s of automatic re-checks, and
the app appears on its own when it answers. Only the headline was wrong. While
those retries are in flight AND the package reports running/starting/restarting
(or health "starting"), the overlay now shows the app's own pulsing icon,
"<App> is starting…", and says the container is running. Once retries are
exhausted the failure is real again and the original copy returns.

Follows the ElectrumX sync-screen precedent already in this file, which
suppresses the same overlay for the same reason. The explicit blocked-reason
and must-open-new-tab paths are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 14:28:17 -04:00
co-authored by Claude Opus 5
parent ab69400956
commit c65ee03a5c
3 changed files with 100 additions and 4 deletions
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import AppSessionFrame from '../AppSessionFrame.vue'
// Regression cover for the operator-reported defect: a container that is up
// but has not finished warming up (bitcoind serving RPC -28, lnd before the
// wallet unlocks) rendered the hard "App not reachable" failure copy for the
// whole warm-up window. The retry machinery already tolerated it — only the
// headline lied.
function mountFrame(props: Record<string, unknown> = {}) {
return mount(AppSessionFrame, {
props: {
appUrl: 'http://localhost:8332/',
appId: 'bitcoin-knots',
appTitle: 'Bitcoin',
appIcon: '/icons/bitcoin.png',
loading: false,
iframeBlocked: true,
mustOpenNewTab: false,
autoRetryCount: 1,
refreshKey: 0,
...props,
},
global: { stubs: { AppLoadingScreen: true, Transition: false } },
})
}
describe('AppSessionFrame warm-up state', () => {
it('reads as starting, not unreachable, while the container is warming up', () => {
const text = mountFrame({ warmingUp: true }).text()
expect(text).toContain('Bitcoin is starting…')
expect(text).not.toContain('App not reachable')
})
it('says the container is running so the copy does not imply it is stopped', () => {
const text = mountFrame({ warmingUp: true }).text()
expect(text).toContain("container is running but hasn't finished warming up")
expect(text).not.toContain('the container is stopped')
})
it('still surfaces the automatic re-check while warming up', () => {
expect(mountFrame({ warmingUp: true, autoRetryCount: 3 }).text()).toContain(
'Checking again automatically (3)',
)
})
it('reverts to the real failure once warm-up is over (retries exhausted)', () => {
const text = mountFrame({ warmingUp: false, autoRetryCount: 6 }).text()
expect(text).toContain('App not reachable')
expect(text).not.toContain('is starting…')
})
it('leaves the explicit blocked-reason path untouched', () => {
const text = mountFrame({
warmingUp: false,
blockedReason: 'Waiting for Bitcoin to finish syncing.',
blockedTitle: 'Waiting for Bitcoin sync',
}).text()
expect(text).toContain('Waiting for Bitcoin sync')
expect(text).not.toContain('App not reachable')
})
it('leaves the new-tab path untouched', () => {
const text = mountFrame({ warmingUp: false, mustOpenNewTab: true }).text()
expect(text).toContain('This app opens in a new tab')
})
})