feat(02-06): cache Home's system/update/storage groups and guarantee wallet freshness on re-entry
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m29s

- System stats (homeStatus.refresh), update status and cloud storage usage
  are now every-entry, TTL-gated useCachedResource entries (10s/300s/30s),
  hosted in Home.vue rather than inside the homeStatus Pinia store — a
  store's own defineStore(id, setup) runs in a bare effectScope where
  onActivated() silently no-ops (same finding as 02-05's Mesh.vue)
- Wallet is the deliberate exception (T-02-13): a new home.wallet-status
  resource wraps the existing loadWeb5Status() composite fetch and
  revalidates UNCONDITIONALLY on every activation rather than TTL-gated,
  keeps the prior figure rendered throughout, and persist:false (never
  written to sessionStorage). hydrateWalletSnapshot()'s separate localStorage
  path is untouched.
- Read Web5.vue's two existing resources (web5.networking-profits,
  web5.lnd-info) and did NOT share either key: profits is an unrelated
  dataset, and lnd-info's own default persist:true (Web5.vue out of this
  plan's file scope) would leak balance data via its own independent
  refresh cycle regardless of what Home declares, and Home's wallet fetch
  is a strictly broader 7-call composite (not the same single-call dataset)
- dedup:true added to all 12 underlying rpcClient calls (Home.vue's wallet
  composite + checkUpdateStatus, homeStatus.ts's 5 status calls)
- RefreshIndicator wired next to the Home header, bound to the wallet
  resource's loadState
- The websocket wallet-push path and hydrateWalletSnapshot's pre-network
  paint are both left exactly as 02-04 placed them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 17:38:01 -04:00
parent e6ed5536c9
commit 926fa60691
4 changed files with 476 additions and 34 deletions

View File

@ -89,7 +89,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
load_avg_1?: number
load_avg_5?: number
load_avg_15?: number
}>({ method: 'system.stats' })
}>({ method: 'system.stats', dedup: true })
stats.cpuPercent = res.cpu_usage_percent
stats.memUsed = res.mem_used_bytes
stats.memTotal = res.mem_total_bytes
@ -114,6 +114,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
timeout: 5000,
dedup: true,
})
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
stats.bitcoinBlockHeight = btc.block_height ?? 0
@ -181,7 +182,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
key_present: boolean
anchor_connected?: boolean
authenticated_peer_count?: number
}>({ method: 'fips.status' })
}>({ method: 'fips.status', dedup: true })
fipsStatus.value = status
fipsLoadState.value = 'ready'
lastFipsRefreshAt.value = Date.now()
@ -196,6 +197,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
const res = await rpcClient.call<{ tollgate: { installed: boolean; enabled?: boolean } }>({
method: 'openwrt.get-status',
timeout: 15000,
dedup: true,
})
tollgateStatus.value = { installed: res.tollgate.installed, enabled: res.tollgate.enabled ?? false }
tollgateLoadState.value = 'ready'

View File

@ -2,9 +2,12 @@
<div class="pb-6">
<div class="mb-4 md:mb-8 flex items-start justify-between gap-4">
<div class="min-h-[4.5rem]">
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">
{{ line1Text }}<span v-if="showCaretLine1" class="typing-caret"></span>
</h1>
<div class="flex items-center gap-2">
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">
{{ line1Text }}<span v-if="showCaretLine1" class="typing-caret"></span>
</h1>
<RefreshIndicator :state="homeRefreshIndicatorState" label="Refreshing wallet balance" />
</div>
<p class="text-white/80">
{{ line2Text }}<span v-if="showCaretLine2" class="typing-caret"></span>
</p>
@ -291,6 +294,8 @@
<script setup lang="ts">
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import WalletScanModal from '@/components/WalletScanModal.vue'
@ -507,8 +512,8 @@ loadQuickStartState()
// Update notification
const updateAvailable = ref(false); const updateDismissed = ref(false); const updateVersion = ref(''); const updateChangelog = ref('')
async function checkUpdateStatus() {
try { const res = await rpcClient.call<{ update_available: boolean }>({ method: 'update.status' }); updateAvailable.value = res.update_available } catch { /* unavailable */ }
if (updateAvailable.value) { try { const detail = await rpcClient.call<{ update: { version: string; changelog: string[] } | null }>({ method: 'update.check' }); if (detail.update) { updateVersion.value = detail.update.version; updateChangelog.value = detail.update.changelog.slice(0, 2).join('; ') } } catch { /* unavailable */ } }
try { const res = await rpcClient.call<{ update_available: boolean }>({ method: 'update.status', dedup: true }); updateAvailable.value = res.update_available } catch { /* unavailable */ }
if (updateAvailable.value) { try { const detail = await rpcClient.call<{ update: { version: string; changelog: string[] } | null }>({ method: 'update.check', dedup: true }); if (detail.update) { updateVersion.value = detail.update.version; updateChangelog.value = detail.update.changelog.slice(0, 2).join('; ') } } catch { /* unavailable */ } }
}
async function dismissUpdate() { updateDismissed.value = true; try { await rpcClient.call({ method: 'update.dismiss' }) } catch { /* ignore */ } }
@ -518,24 +523,96 @@ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; con
const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? formatBytes(cloudStorageUsed.value) : '...')
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
// Only-while-visible side effects (system stats poll, wallet poll, wallet
// websocket push) are armed on every activation (including the first mount
// Vue fires onActivated on first mount too) and torn down on deactivation, so
// an off-screen Home costs nothing and a re-entered Home revalidates the
// liveness-critical wallet figures immediately rather than waiting out the
// 10s/30s intervals (T-02-13). Idempotent: any existing handle is cleared
// before a new one is armed, so two consecutive activations never double-arm.
// 02-06: system stats, update status and cloud storage usage on keyed
// useCachedResource entries (side-effect-only, per the Mesh.vue/02-05
// pattern each fetcher wraps an existing function that already sets its
// own reactive refs, resolving to a sentinel timestamp rather than the real
// payload). homeStatus.refresh() is a Pinia store action, so mirroring
// 02-05's finding that a store's own defineStore(id, setup) runs in a bare
// effectScope where onActivated() silently no-ops the useCachedResource
// wrapper lives here in Home.vue, not inside stores/homeStatus.ts.
//
// Web5.vue's own two resources (web5.networking-profits, web5.lnd-info)
// were read and are NOT shared here: web5.networking-profits is an
// unrelated dataset (routing/content-sale profit totals), and
// web5.lnd-info's default persist:true (Web5.vue is out of this plan's
// file scope to fix) would leak balance data to sessionStorage via its own
// independent refresh cycle regardless of what Home declares for the same
// key sharing that key would either corrupt Web5.vue's differently-shaped
// entry.data (a real number here vs. its typed balance object there) or
// silently fail to close the sessionStorage gap this task exists to close.
// Home's own wallet fetch is also a strictly broader 7-call composite
// (lnd.getinfo + ecash/fedimint/ark balances + 3 histories), not the same
// single-call dataset. See 02-06-SUMMARY.md for the full finding.
const systemStatsRes = useCachedResource<number>({
key: 'home.system-stats',
immediate: false,
ttlMs: 10_000, // matches the pre-existing 10s poll cadence
persist: true, // aggregate system/bitcoin/vpn/fips/tollgate status, no identity payload
fetcher: async () => { await loadSystemStats(); return Date.now() },
})
const updateStatusRes = useCachedResource<number>({
key: 'home.update-status',
immediate: false,
ttlMs: 300_000, // near-static an available update doesn't appear/disappear quickly
persist: true,
fetcher: async () => { await checkUpdateStatus(); return Date.now() },
})
const cloudUsageRes = useCachedResource<number>({
key: 'home.cloud-usage',
immediate: false,
ttlMs: 30_000, // default tier
persist: true,
fetcher: async () => {
try {
const usage = await fileBrowserClient.getUsage()
cloudStorageUsed.value = usage.totalSize
cloudFolderCount.value = usage.folderCount
} catch { /* not running */ }
return Date.now()
},
})
const homeCachedGroups = [systemStatsRes, updateStatusRes, cloudUsageRes]
function refreshHomeGroupIfStale(res: typeof systemStatsRes): Promise<void> {
if (res.entry.data === null || res.isStale.value) return res.refresh()
return Promise.resolve()
}
// Wallet is the exception (T-02-13): a money figure must never be presented
// as current without a visible re-check, so this resource revalidates
// UNCONDITIONALLY on every activation rather than TTL-gated never via
// refreshHomeGroupIfStale above. persist:false the fetcher wraps
// loadWeb5Status(), which sets the wallet balance/transaction refs directly
// as a side effect; hydrateWalletSnapshot() (localStorage, a separate
// mechanism) is the view's own last-known-figures path and is untouched by
// this resource.
const walletStatusRes = useCachedResource<number>({
key: 'home.wallet-status',
immediate: false,
persist: false,
fetcher: async () => { await loadWeb5Status(); return Date.now() },
})
// Only-while-visible side effects (system/update/storage cache revalidation,
// wallet poll, wallet websocket push) are armed on every activation
// (including the first mount Vue fires onActivated on first mount too)
// and torn down on deactivation, so an off-screen Home costs nothing and a
// re-entered Home revalidates the liveness-critical wallet figures
// immediately rather than waiting out the 10s/30s intervals (T-02-13).
// Idempotent: any existing handle is cleared before a new one is armed, so
// two consecutive activations never double-arm.
function armLiveDataPolling() {
loadSystemStats()
void Promise.allSettled(homeCachedGroups.map(refreshHomeGroupIfStale))
if (systemStatsInterval) clearInterval(systemStatsInterval)
systemStatsInterval = setInterval(loadSystemStats, 10000)
systemStatsInterval = setInterval(() => void systemStatsRes.refresh(), 10000)
// Poll wallet balances/transactions like Web5.vue does without this a
// pending on-chain receive (or a fresh instant payment) only shows up
// after a manual wallet action or a remount.
void loadWeb5Status()
// after a manual wallet action or a remount. Unconditional refresh, not
// staleness-gated (T-02-13) see walletStatusRes above.
void walletStatusRes.refresh()
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
walletRefreshInterval = setInterval(loadWeb5Status, 30000)
walletRefreshInterval = setInterval(() => void walletStatusRes.refresh(), 30000)
// Real-time wallet push (2026-07-22): the backend streams LND transaction
// events and nudges /ws/db the moment a tx hits the mempool. Any push =
@ -545,10 +622,15 @@ function armLiveDataPolling() {
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
unsubscribeWs = wsClient.subscribe(() => {
if (wsWalletDebounce) clearTimeout(wsWalletDebounce)
wsWalletDebounce = setTimeout(() => { void loadWeb5Status() }, 800)
wsWalletDebounce = setTimeout(() => { void walletStatusRes.refresh() }, 800)
})
}
// Drives the header RefreshIndicator (D-05/T-02-13): visible whenever the
// wallet resource is revalidating, so a resumed tab never presents a
// paused-poll balance as current without a visible signal.
const homeRefreshIndicatorState = computed(() => walletStatusRes.loadState.value)
function disarmLiveDataPolling() {
if (systemStatsInterval) { clearInterval(systemStatsInterval); systemStatsInterval = null }
if (walletRefreshInterval) { clearInterval(walletRefreshInterval); walletRefreshInterval = null }
@ -569,15 +651,18 @@ onActivated(() => {
})
onDeactivated(() => { disarmLiveDataPolling() })
onMounted(async () => {
onMounted(() => {
// Once-per-session: paint last-known wallet figures BEFORE any network
// round-trip, check for an available update, and read the cloud usage
// summary once. None of these are polled, so they stay here rather than
// moving to onActivated a resumed tab doesn't need them re-run.
// round-trip. Not polled, so it stays here rather than moving to
// onActivated a resumed tab doesn't need it re-run.
hydrateWalletSnapshot()
checkUpdateStatus()
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// 02-06: update status and cloud storage usage are now every-entry,
// TTL-gated cached resources (see homeCachedGroups above) armed
// concurrently (Promise.allSettled, no sequential await chain) alongside
// system stats and the wallet from the single armLiveDataPolling() call
// below, rather than a separate trailing-await block here.
//
// Also arm here directly: onActivated is a no-op outside a <KeepAlive>
// boundary (Vue only auto-fires it on first mount for a component that
// already has a KeepAlive ancestor), so a bare mount a unit test, or any
@ -675,7 +760,7 @@ async function loadWeb5Status() {
// block cost 7 × (mesh RTT + backend time); parallel it costs one slowest
// call, which is what makes the card feel like an app launch.
const balances = Promise.allSettled([
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
.catch(() => {
// A single slow poll must NOT flip the card to "disconnected" and
@ -686,21 +771,21 @@ async function loadWeb5Status() {
walletInfoFailures += 1
if (walletInfoFailures >= 3) walletConnected.value = false
}),
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000, dedup: true })
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000, dedup: true })
.then(res => { walletFedimint.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 })
rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000, dedup: true })
.then(res => { walletArk.value = res.spendable_sats ?? 0 }).catch(() => { /* keep last-known */ }),
])
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
// already unifies both) so Cashu/Fedimint receives appear in the modal.
const histories = Promise.allSettled([
rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 })
rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000, dedup: true })
.then(res => (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const }))).catch(() => [] as WalletTransaction[]),
rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 })
rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000, dedup: true })
.then(res => res.transactions || []).catch(() => [] as WalletTransaction[]),
rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 })
rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000, dedup: true })
.then(res => (res.transactions || []).map(ecashToWalletTransaction)).catch(() => [] as WalletTransaction[]),
]).then((results) => {
const merged = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
@ -732,6 +817,11 @@ async function loadSystemStats() {
}
function uploadFiles() { const pkg = packages.value['filebrowser']; if (pkg && pkg.state === PackageState.Running) { const host = window.location.hostname; useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' }) } else { router.push('/dashboard/cloud') } }
defineExpose({
loadWeb5Status,
homeRefreshIndicatorState,
})
</script>
<style scoped>

View File

@ -0,0 +1,351 @@
// 02-06 Task 2: caches Home's system stats, update status and cloud storage
// usage behind keyed useCachedResource entries (every-entry, TTL-gated), and
// wraps the existing loadWeb5Status() wallet fetch in its own resource that
// revalidates UNCONDITIONALLY on every activation (T-02-13) — a money figure
// must never be presented as current without a visible re-check. Web5.vue's
// own two resources (web5.networking-profits, web5.lnd-info) were read and
// are NOT shared here — see 02-06-SUMMARY.md for the finding.
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Home from '../Home.vue'
import HomeWalletCard from '../home/HomeWalletCard.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/api/websocket', () => ({
wsClient: { subscribe: vi.fn(() => vi.fn()) },
}))
vi.mock('@/api/filebrowser-client', () => ({
fileBrowserClient: {
getUsage: vi.fn().mockResolvedValue({ totalSize: 1024, folderCount: 2, fileCount: 5 }),
},
}))
// Records every rpcClient.call({method}) invocation. wallet/system/update
// calls all route through call(); nothing Home.vue uses goes through a
// separate convenience method (unlike Server.vue's vpnStatus/dnsStatus).
async function defaultRpcCallImpl({ method }: { method: string }) {
switch (method) {
case 'system.stats':
return { cpu_usage_percent: 10, mem_used_bytes: 100, mem_total_bytes: 200, disk_used_bytes: 1, disk_total_bytes: 2, uptime_secs: 60 }
case 'bitcoin.getinfo':
return { block_height: 100, sync_progress: 1 }
case 'fips.status':
return { installed: false, service_active: false, key_present: false }
case 'openwrt.get-status':
return { tollgate: { installed: false } }
case 'update.status':
return { update_available: false }
case 'lnd.getinfo':
return { balance_sats: 5000, channel_balance_sats: 2500, synced_to_chain: true }
case 'wallet.ecash-balance':
return { balance_sats: 100 }
case 'wallet.fedimint-balance':
return { balance_sats: 0 }
case 'wallet.ark-balance':
return { spendable_sats: 0 }
case 'lnd.gettransactions':
return { transactions: [], incoming_pending_count: 0 }
case 'lnd.lightning-history':
return { transactions: [] }
case 'wallet.ecash-history':
return { transactions: [] }
default:
return {}
}
}
const rpcCallMock = vi.fn(defaultRpcCallImpl)
const vpnStatusMock = vi.fn(async () => ({
connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '',
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
vpnStatus: (...args: unknown[]) => (vpnStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountHomeHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Home, { key: 'home' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
HomeSystemCard: true,
EasyHome: true,
WalletScanModal: true,
SendBitcoinModal: true,
ReceiveBitcoinModal: true,
TransactionsModal: true,
WalletSettingsModal: true,
},
},
})
}
function callCountFor(method: string): number {
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
}
async function toggleTab(wrapper: ReturnType<typeof mountHomeHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
// loadWeb5Status()'s nested Promise.allSettled chains (balances + histories,
// each wrapping several rpcClient.call().then() hops) need more than one
// macrotask boundary to fully settle under fake timers — a single
// flushPromises() left the wallet resource's loadState at 'loading' in
// practice. Two calls reliably drain it.
async function settle() {
await flushPromises()
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
rpcCallMock.mockClear()
// Guard against a leaking permanent mockImplementation() override from a
// prior test (e.g. the never-resolving-promise case below) — mockClear()
// only clears call history, not a permanently swapped implementation.
rpcCallMock.mockImplementation(defaultRpcCallImpl)
vpnStatusMock.mockClear()
try {
sessionStorage.clear()
localStorage.clear()
} catch { /* unavailable in some envs */ }
})
afterEach(() => {
vi.useRealTimers()
})
describe('Home tab cache (Task 2): system/update/storage groups + wallet freshness', () => {
it('reactivating inside the TTL issues zero new RPCs for the system, update and storage-usage groups', async () => {
const wrapper = mountHomeHost()
await settle()
const before = {
stats: callCountFor('system.stats'),
btc: callCountFor('bitcoin.getinfo'),
fips: callCountFor('fips.status'),
tollgate: callCountFor('openwrt.get-status'),
vpn: vpnStatusMock.mock.calls.length,
update: callCountFor('update.status'),
usage: vi.mocked((await import('@/api/filebrowser-client')).fileBrowserClient.getUsage).mock.calls.length,
}
await toggleTab(wrapper, false)
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
await toggleTab(wrapper, true)
await settle()
expect(callCountFor('system.stats')).toBe(before.stats)
expect(callCountFor('bitcoin.getinfo')).toBe(before.btc)
expect(callCountFor('fips.status')).toBe(before.fips)
expect(callCountFor('openwrt.get-status')).toBe(before.tollgate)
expect(vpnStatusMock.mock.calls.length).toBe(before.vpn)
expect(callCountFor('update.status')).toBe(before.update)
expect(vi.mocked((await import('@/api/filebrowser-client')).fileBrowserClient.getUsage).mock.calls.length).toBe(before.usage)
wrapper.unmount()
})
it('reactivating always triggers a wallet revalidation even when the TTL has not lapsed', async () => {
const wrapper = mountHomeHost()
await settle()
const lndCallsAtMount = callCountFor('lnd.getinfo')
expect(lndCallsAtMount).toBeGreaterThan(0)
await toggleTab(wrapper, false)
vi.advanceTimersByTime(1000) // nowhere near stale by any TTL definition
await toggleTab(wrapper, true)
await settle()
// Unconditional — fires again regardless of staleness (T-02-13).
expect(callCountFor('lnd.getinfo')).toBe(lndCallsAtMount + 1)
wrapper.unmount()
})
it('the previously rendered wallet figure stays in the DOM throughout a wallet revalidation', async () => {
const wrapper = mount(Home, {
global: {
plugins: [createPinia()],
stubs: {
HomeSystemCard: true,
EasyHome: true,
WalletScanModal: true,
SendBitcoinModal: true,
ReceiveBitcoinModal: true,
TransactionsModal: true,
WalletSettingsModal: true,
},
},
})
await settle()
const walletCardBefore = wrapper.findComponent(HomeWalletCard)
expect(walletCardBefore.exists()).toBe(true)
expect(walletCardBefore.props('walletOnchain')).toBe(5000)
// Trigger a fresh revalidation directly (mirrors what reactivation does)
// and assert the card's props are still populated with the prior figure
// before the new fetch resolves — never blanked/reset to 0 mid-flight.
let resolveLnd!: (v: unknown) => void
rpcCallMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLnd = resolve as (v: unknown) => void }) as ReturnType<typeof defaultRpcCallImpl>)
const pending = (wrapper.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
await wrapper.vm.$nextTick()
const walletCardDuring = wrapper.findComponent(HomeWalletCard)
expect(walletCardDuring.props('walletOnchain')).toBe(5000)
resolveLnd({ balance_sats: 6000, channel_balance_sats: 2500, synced_to_chain: true })
await pending
await settle()
wrapper.unmount()
})
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
const wrapper = mountHomeHost()
await settle()
await toggleTab(wrapper, false)
vi.advanceTimersByTime(1000)
await toggleTab(wrapper, true)
await settle()
const readSnapshot = (key: string) => {
try {
return sessionStorage.getItem(`resource:${key}`)
} catch {
return null
}
}
expect(readSnapshot('home.wallet-status')).toBeNull()
// Non-sensitive groups may persist.
expect(readSnapshot('home.system-stats')).not.toBeNull()
expect(readSnapshot('home.update-status')).not.toBeNull()
expect(readSnapshot('home.cloud-usage')).not.toBeNull()
wrapper.unmount()
})
it('hydrateWalletSnapshot still paints last-known figures before any network round-trip', async () => {
localStorage.setItem('archy-wallet-snapshot-v1', JSON.stringify({
onchain: 42000, lightning: 1000, ecash: 0, fedimint: 0, ark: 0, connected: true, transactions: [],
}))
// Defer every RPC indefinitely so nothing resolves before the assertion.
rpcCallMock.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const wrapper = mount(Home, {
global: {
plugins: [createPinia()],
stubs: {
HomeSystemCard: true,
EasyHome: true,
WalletScanModal: true,
SendBitcoinModal: true,
ReceiveBitcoinModal: true,
TransactionsModal: true,
WalletSettingsModal: true,
},
},
})
await wrapper.vm.$nextTick()
const walletCard = wrapper.findComponent(HomeWalletCard)
expect(walletCard.props('walletOnchain')).toBe(42000)
expect(walletCard.props('walletLightning')).toBe(1000)
wrapper.unmount()
})
it('the websocket wallet-push path (02-04) is still present and still triggers a wallet refresh', async () => {
const { wsClient } = await import('@/api/websocket')
const wrapper = mountHomeHost()
await settle()
expect(vi.mocked(wsClient.subscribe)).toHaveBeenCalled()
const subscribeCalls = vi.mocked(wsClient.subscribe).mock.calls
const pushHandler = subscribeCalls[0]?.[0] as (() => void) | undefined
expect(pushHandler).toBeDefined()
const lndCallsBefore = callCountFor('lnd.getinfo')
pushHandler?.()
vi.advanceTimersByTime(800) // the debounce window
await settle()
expect(callCountFor('lnd.getinfo')).toBe(lndCallsBefore + 1)
wrapper.unmount()
})
it('renders RefreshIndicator bound to the wallet resource\'s loadState', async () => {
const wrapper = mountHomeHost()
await settle()
const indicator = wrapper.findComponent(RefreshIndicator)
expect(indicator.exists()).toBe(true)
expect(indicator.props('state')).toBe('ready')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(1000)
await toggleTab(wrapper, true)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
await settle()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
wrapper.unmount()
})
it('every fetcher backing the wallet composite call passes dedup:true', async () => {
const wrapper = mountHomeHost()
await settle()
const walletMethods = [
'lnd.getinfo', 'wallet.ecash-balance', 'wallet.fedimint-balance', 'wallet.ark-balance',
'lnd.gettransactions', 'lnd.lightning-history', 'wallet.ecash-history',
]
const dedupFlags = rpcCallMock.mock.calls
.filter(([opts]) => walletMethods.includes((opts as { method: string }).method))
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
expect(dedupFlags.length).toBeGreaterThanOrEqual(walletMethods.length)
expect(dedupFlags.every(Boolean)).toBe(true)
wrapper.unmount()
})
})

View File

@ -11,7 +11,6 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Server from '../Server.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),