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
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:
parent
e6ed5536c9
commit
926fa60691
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
351
neode-ui/src/views/__tests__/homeTabCache.test.ts
Normal file
351
neode-ui/src/views/__tests__/homeTabCache.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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: {} }),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user