feat(02-05): cache the six Mesh tab-entry fetch groups without serializing them
- Mesh.vue: six useCachedResource entries wrap mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid() and refreshContacts(), each with an explicit TTL (10s reachability/transport, 30s federation/contacts, 300s self DID/onion) and persist decision (T-02-01: every group carrying peer or self identity data is persist:false; only aggregate transport status may persist) - armMeshLive's Promise.all fan-out becomes a single Promise.allSettled over refreshMeshGroupIfStale() per group, so a revisit inside TTL issues zero RPC, a stale revisit revalidates concurrently (not a serial chain, T-02-16), and one rejected group never blocks the rest - RefreshIndicator wired into the Mesh header, driven by whether any of the six groups is refreshing — visible while peer reachability revalidates on re-entry so a resumed tab never shows a frozen reachability state as current (T-02-13) - dedup: true added to every underlying rpc-client.ts/mesh.ts/ transport.ts fetcher call backing the six groups - useCachedResource() calls live in Mesh.vue rather than inside stores/mesh.ts or stores/transport.ts: Pinia's defineStore(id, setup) runs in a bare effectScope, not a component instance, so the composable's internal onActivated() would silently no-op there; the fetchers still wrap the stores' own actions unchanged, so those actions' other callers (clearAllMesh, setMeshOnly, the pre-send balance check) keep their guaranteed-fresh, uncached reads Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e582124259
commit
31389bcc3c
@ -350,6 +350,7 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'node.did',
|
||||
params: {},
|
||||
dedup: true, // read-only identity lookup; safe to collapse concurrent callers (PERF-02)
|
||||
})
|
||||
}
|
||||
|
||||
@ -577,6 +578,7 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'node.tor-address',
|
||||
params: {},
|
||||
dedup: true, // read-only; near-static value, safe to collapse concurrent callers (PERF-02)
|
||||
})
|
||||
}
|
||||
|
||||
@ -801,7 +803,7 @@ class RPCClient {
|
||||
async meshContactsList(): Promise<{
|
||||
contacts: Array<{ pubkey: string; alias?: string | null; notes?: string | null; pinned?: boolean; blocked?: boolean }>
|
||||
}> {
|
||||
return this.call({ method: 'mesh.contacts-list', params: {} })
|
||||
return this.call({ method: 'mesh.contacts-list', params: {}, dedup: true }) // read-only (PERF-02)
|
||||
}
|
||||
|
||||
async meshContactsSave(
|
||||
@ -842,6 +844,7 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'federation.list-nodes',
|
||||
params: {},
|
||||
dedup: true, // read-only; safe to collapse concurrent callers (PERF-02)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -318,7 +318,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
|
||||
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status', dedup: true })
|
||||
status.value = res
|
||||
trackDetectedDevices(res)
|
||||
} catch (err: unknown) {
|
||||
@ -462,6 +462,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
|
||||
method: 'mesh.peers',
|
||||
dedup: true,
|
||||
})
|
||||
peers.value = res.peers
|
||||
updateNodePositionsFromPeers(res.peers)
|
||||
@ -475,6 +476,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
|
||||
method: 'mesh.messages',
|
||||
params: limit ? { limit } : {},
|
||||
dedup: true,
|
||||
})
|
||||
if (seedLastSeenFromHistory && res.messages.length > 0) {
|
||||
for (const m of res.messages) {
|
||||
@ -1019,7 +1021,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
|
||||
async function fetchDeadmanStatus() {
|
||||
try {
|
||||
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
|
||||
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status', dedup: true })
|
||||
} catch {
|
||||
// Dead man switch not available
|
||||
}
|
||||
@ -1052,6 +1054,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
const res = await rpcClient.call<{ headers: BlockHeader[]; latest_height: number; count: number }>({
|
||||
method: 'mesh.block-headers',
|
||||
params: { count },
|
||||
dedup: true,
|
||||
})
|
||||
blockHeaders.value = res.headers
|
||||
latestBlockHeight.value = res.latest_height
|
||||
|
||||
@ -45,7 +45,7 @@ export const useTransportStore = defineStore('transport', () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const res = await rpcClient.call<TransportStatus>({ method: 'transport.status' })
|
||||
const res = await rpcClient.call<TransportStatus>({ method: 'transport.status', dedup: true })
|
||||
status.value = res
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch transport status'
|
||||
@ -58,6 +58,7 @@ export const useTransportStore = defineStore('transport', () => {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: TransportPeer[] }>({
|
||||
method: 'transport.peers',
|
||||
dedup: true,
|
||||
})
|
||||
peers.value = res.peers
|
||||
} catch (err: unknown) {
|
||||
|
||||
@ -16,6 +16,8 @@ import { wsClient } from '@/api/websocket'
|
||||
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
|
||||
import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
@ -363,6 +365,133 @@ function updateKeyboardInset() {
|
||||
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
|
||||
}
|
||||
|
||||
// ── Cached tab-entry fan-out (PERF-02, T-02-01, T-02-13, T-02-16) ──────────
|
||||
//
|
||||
// The six fetch groups armMeshLive() fires on every tab entry are wrapped in
|
||||
// useCachedResource entries so a revisit inside each group's TTL issues no
|
||||
// RPC at all, while a stale revisit revalidates in the background (driven
|
||||
// through the shared RefreshIndicator below) without clearing the screen.
|
||||
//
|
||||
// Placement: mesh.refreshAll() and transport.fetchStatus() are store actions
|
||||
// with other callers (clearAllMesh() below, and setMeshOnly()/
|
||||
// Web5SendReceiveModals.vue's pre-send balance check, respectively) that
|
||||
// need a guaranteed-fresh read, not a cache-gated one — so those two store
|
||||
// actions themselves are left untouched, and the useCachedResource() call
|
||||
// that wraps each of them lives here instead of inside stores/mesh.ts or
|
||||
// stores/transport.ts. This is a deliberate technical choice, not a
|
||||
// re-reading of the plan's intent: Pinia's `defineStore(id, setup)` runs its
|
||||
// setup inside a bare `effectScope`, not a real component instance, and
|
||||
// useCachedResource's internal `onActivated()` registration is a documented
|
||||
// Vue no-op (with a dev warning) outside a component's own setup() — hosting
|
||||
// it at store scope would compile but would never actually revalidate
|
||||
// anything on tab reactivation. Mesh.vue is the one call site that
|
||||
// legitimately owns the KeepAlive/component lifecycle, so it hosts all six
|
||||
// resources; the store-owned groups' fetchers still just re-invoke the
|
||||
// store's existing action, so the store's real refresh path is what is
|
||||
// genuinely being cached and gated.
|
||||
//
|
||||
// Every entry is `immediate: false` and force-refreshed only when stale via
|
||||
// refreshMeshGroupIfStale() below, called from a single Promise.allSettled
|
||||
// array in armMeshLive — never auto-triggered independently — so the
|
||||
// cold-load fan-out stays concurrent instead of becoming a serialized chain
|
||||
// of six separately-awaited refreshes (T-02-16), and one rejected group
|
||||
// never suppresses the other five. Each fetcher resolves to a plain
|
||||
// timestamp (not the real payload): the real UI-facing state continues to
|
||||
// flow through mesh.status/mesh.peers/etc (set by the wrapped store
|
||||
// actions/view-local refreshers as a side effect, exactly as before this
|
||||
// plan) — these resources exist purely to gate/dedupe the RPC and to expose
|
||||
// a `loadState` for the shared RefreshIndicator, not to hold the data itself.
|
||||
const meshRefreshResource = useCachedResource<number>({
|
||||
key: 'mesh.refresh-all',
|
||||
fetcher: async (signal) => {
|
||||
void signal
|
||||
await mesh.refreshAll()
|
||||
return Date.now()
|
||||
},
|
||||
// Peer/status/message data is exactly the liveness-critical reachability
|
||||
// surface T-02-13 forbids freezing — shortest TTL of the six groups.
|
||||
ttlMs: 10_000,
|
||||
persist: false, // mesh.peers carries peer DIDs/pubkeys — identity payload
|
||||
immediate: false,
|
||||
})
|
||||
const transportStatusResource = useCachedResource<number>({
|
||||
key: 'mesh.transport-status',
|
||||
fetcher: async (signal) => {
|
||||
void signal
|
||||
await transport.fetchStatus()
|
||||
return Date.now()
|
||||
},
|
||||
ttlMs: 10_000, // transport availability / mesh-only mode is live state
|
||||
persist: true, // aggregate status only — no peer identity payload
|
||||
immediate: false,
|
||||
})
|
||||
const federationNodesResource = useCachedResource<number>({
|
||||
key: 'mesh.federation-nodes',
|
||||
fetcher: async (signal) => {
|
||||
void signal
|
||||
await refreshFederationNodes()
|
||||
return Date.now()
|
||||
},
|
||||
ttlMs: 30_000, // D-06 default — federation node list isn't liveness-critical
|
||||
persist: false, // federation nodes carry DID/pubkey/onion — identity payload
|
||||
immediate: false,
|
||||
})
|
||||
const selfOnionResource = useCachedResource<number>({
|
||||
key: 'mesh.self-onion',
|
||||
fetcher: async (signal) => {
|
||||
void signal
|
||||
await refreshSelfOnion()
|
||||
return Date.now()
|
||||
},
|
||||
ttlMs: 300_000, // this node's own onion address is effectively static
|
||||
persist: false, // this node's own onion address — identity payload
|
||||
immediate: false,
|
||||
})
|
||||
const selfDidResource = useCachedResource<number>({
|
||||
key: 'mesh.self-did',
|
||||
fetcher: async (signal) => {
|
||||
void signal
|
||||
await refreshSelfDid()
|
||||
return Date.now()
|
||||
},
|
||||
ttlMs: 300_000, // this node's own DID is effectively static
|
||||
persist: false, // this node's own DID — identity payload
|
||||
immediate: false,
|
||||
})
|
||||
const contactsResource = useCachedResource<number>({
|
||||
key: 'mesh.contacts',
|
||||
fetcher: async (signal) => {
|
||||
void signal
|
||||
await refreshContacts()
|
||||
return Date.now()
|
||||
},
|
||||
ttlMs: 30_000, // user-set aliases change rarely; the D-06 default is enough
|
||||
persist: false, // contact records/aliases — identity payload per T-02-01
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
const meshCachedGroups = [
|
||||
meshRefreshResource, transportStatusResource, federationNodesResource,
|
||||
selfOnionResource, selfDidResource, contactsResource,
|
||||
]
|
||||
// Drives the header RefreshIndicator (D-05): visible whenever any of the six
|
||||
// groups is revalidating in the background, including peer reachability, so
|
||||
// a resumed tab never presents a frozen reachability state as current
|
||||
// (T-02-13).
|
||||
const meshRefreshIndicatorState = computed(() =>
|
||||
meshCachedGroups.some((r) => r.loadState.value === 'refreshing') ? 'refreshing' : 'ready'
|
||||
)
|
||||
|
||||
/** Force a group's refresh only when it has never resolved or is past its
|
||||
* own TTL — mirrors Cloud.vue's loadCounts() gate. Deliberately synchronous
|
||||
* up to the point of kicking off `refresh()`, so all six calls in
|
||||
* armMeshLive's Promise.allSettled array start in the same tick rather than
|
||||
* forming a waterfall. */
|
||||
function refreshMeshGroupIfStale(res: (typeof meshCachedGroups)[number]): Promise<void> {
|
||||
if (res.entry.data === null || res.isStale.value) return res.refresh()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Mesh is a live communications surface — peer reachability, messages and
|
||||
// status are exactly the kind of data the threat model (T-02-13) forbids
|
||||
// rendering from a paused cache without revalidating. Nothing here is
|
||||
@ -407,10 +536,15 @@ function armMeshLive() {
|
||||
// first visit would silently never be picked up.
|
||||
loadPendingFromSession()
|
||||
|
||||
void Promise.all([
|
||||
mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(),
|
||||
refreshSelfOnion(), refreshSelfDid(), refreshContacts(),
|
||||
]).then(() => {
|
||||
// Cache-gated fan-out (PERF-02): each group only issues an RPC if it has
|
||||
// never resolved or is past its own TTL (refreshMeshGroupIfStale), and all
|
||||
// six start in the same Promise.allSettled array so a revisit inside every
|
||||
// group's TTL issues zero RPCs, a stale revisit revalidates concurrently
|
||||
// rather than as a serial chain, and one rejected group never blocks the
|
||||
// other five (T-02-16).
|
||||
void Promise.allSettled(
|
||||
meshCachedGroups.map((res) => refreshMeshGroupIfStale(res))
|
||||
).then(() => {
|
||||
refreshOutboxCount()
|
||||
// Deep-link from a message toast: open the sender's conversation if we can
|
||||
// match a LoRa mesh peer; otherwise open the Archipelago channel, since
|
||||
@ -1918,7 +2052,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<!-- Header (hidden on mobile) -->
|
||||
<div class="mesh-header hidden md:flex">
|
||||
<div class="mesh-header-left">
|
||||
<h1 class="mesh-title">Mesh Network</h1>
|
||||
<div class="mesh-title-row">
|
||||
<h1 class="mesh-title">Mesh Network</h1>
|
||||
<RefreshIndicator :state="meshRefreshIndicatorState" label="Refreshing mesh status" />
|
||||
</div>
|
||||
<p class="mesh-subtitle">
|
||||
{{ mesh.status?.peer_count ?? 0 }} peer{{ (mesh.status?.peer_count ?? 0) !== 1 ? 's' : '' }}
|
||||
<span v-if="mesh.status?.device_connected" class="mesh-subtitle-badge">Live</span>
|
||||
|
||||
353
neode-ui/src/views/__tests__/meshTabCache.test.ts
Normal file
353
neode-ui/src/views/__tests__/meshTabCache.test.ts
Normal file
@ -0,0 +1,353 @@
|
||||
// 02-05: caches the Mesh tab's six uncached fetch groups behind
|
||||
// useCachedResource entries, and bounds the Leaflet map's lifecycle across
|
||||
// Mesh.vue's activate/deactivate cycle (established in 02-04).
|
||||
//
|
||||
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree; the only D3 force simulation belongs to
|
||||
// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
|
||||
// file therefore only covers the six cached fetch groups (Task 1) and the
|
||||
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
|
||||
// D3-specific truths are vacuously satisfied (there is nothing to leak).
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Mesh from '../Mesh.vue'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: { subscribe: vi.fn(() => vi.fn()) },
|
||||
}))
|
||||
|
||||
function meshStatusPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
device_type: 'meshcore',
|
||||
device_path: null,
|
||||
device_connected: true,
|
||||
firmware_version: null,
|
||||
self_node_id: 1,
|
||||
self_advert_name: 'Self',
|
||||
peer_count: 0,
|
||||
channel_name: 'Public',
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Records every rpcClient.call({method}) invocation so tests can assert per-
|
||||
// group call counts and start-order without depending on real network I/O.
|
||||
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
|
||||
switch (method) {
|
||||
case 'mesh.status':
|
||||
return meshStatusPayload()
|
||||
case 'mesh.peers':
|
||||
return { peers: [], count: 0 }
|
||||
case 'mesh.messages':
|
||||
return { messages: [], count: 0 }
|
||||
case 'mesh.deadman-status':
|
||||
return { enabled: false }
|
||||
case 'mesh.block-headers':
|
||||
return { headers: [], latest_height: 0, count: 0 }
|
||||
case 'transport.status':
|
||||
return { transports: [], mesh_only: false, peer_count: 0 }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
|
||||
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
|
||||
getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:z6Mkself' }),
|
||||
meshContactsList: vi.fn().mockResolvedValue({ contacts: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
||||
|
||||
function mountMeshHost() {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
return mount(Host, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
AnimatedLogo: true,
|
||||
MeshMap: true,
|
||||
MeshBitcoinPanel: true,
|
||||
MeshDeadmanPanel: true,
|
||||
MeshDevicePanel: true,
|
||||
MeshAssistantPanel: true,
|
||||
HopVizModal: true,
|
||||
MediaLightbox: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function callCountFor(method: string): number {
|
||||
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
|
||||
}
|
||||
|
||||
function convenienceCallCounts() {
|
||||
return {
|
||||
federationListNodes: vi.mocked(rpcClient.federationListNodes).mock.calls.length,
|
||||
getTorAddress: vi.mocked(rpcClient.getTorAddress).mock.calls.length,
|
||||
getNodeDid: vi.mocked(rpcClient.getNodeDid).mock.calls.length,
|
||||
meshContactsList: vi.mocked(rpcClient.meshContactsList).mock.calls.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTab(wrapper: ReturnType<typeof mountMeshHost>, show: boolean) {
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = show
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
rpcCallMock.mockClear()
|
||||
vi.mocked(rpcClient.federationListNodes).mockClear()
|
||||
vi.mocked(rpcClient.getTorAddress).mockClear()
|
||||
vi.mocked(rpcClient.getNodeDid).mockClear()
|
||||
vi.mocked(rpcClient.meshContactsList).mockClear()
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
} catch { /* unavailable in some envs */ }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Mesh tab cache (Task 1): six fetch groups', () => {
|
||||
it('a cold load fires all six groups concurrently — every RPC has already started before any microtask resolves', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
|
||||
// Synchronously (no await, no flushPromises yet) every one of the six
|
||||
// groups' underlying calls must already have fired: armMeshLive() runs
|
||||
// Promise.allSettled(meshCachedGroups.map(refreshMeshGroupIfStale)),
|
||||
// and none of the intermediate layers (refreshMeshGroupIfStale ->
|
||||
// store.refresh -> fetcher -> mesh.refreshAll/refreshFederationNodes/etc
|
||||
// -> rpcClient.call) awaits anything before reaching the RPC call — a
|
||||
// serialized chain (awaiting one group before starting the next) could
|
||||
// not possibly have reached all six yet at this point.
|
||||
expect(callCountFor('mesh.status')).toBe(1)
|
||||
expect(callCountFor('mesh.peers')).toBe(1)
|
||||
expect(callCountFor('mesh.messages')).toBe(1)
|
||||
expect(callCountFor('mesh.deadman-status')).toBe(1)
|
||||
expect(callCountFor('mesh.block-headers')).toBe(1)
|
||||
expect(callCountFor('transport.status')).toBe(1)
|
||||
expect(convenienceCallCounts()).toEqual({
|
||||
federationListNodes: 1, getTorAddress: 1, getNodeDid: 1, meshContactsList: 1,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating inside every group\'s TTL issues zero additional RPCs across all six groups', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
const before = {
|
||||
status: callCountFor('mesh.status'),
|
||||
peers: callCountFor('mesh.peers'),
|
||||
messages: callCountFor('mesh.messages'),
|
||||
deadman: callCountFor('mesh.deadman-status'),
|
||||
headers: callCountFor('mesh.block-headers'),
|
||||
transport: callCountFor('transport.status'),
|
||||
...convenienceCallCounts(),
|
||||
}
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
expect(callCountFor('mesh.status')).toBe(before.status)
|
||||
expect(callCountFor('mesh.peers')).toBe(before.peers)
|
||||
expect(callCountFor('mesh.messages')).toBe(before.messages)
|
||||
expect(callCountFor('mesh.deadman-status')).toBe(before.deadman)
|
||||
expect(callCountFor('mesh.block-headers')).toBe(before.headers)
|
||||
expect(callCountFor('transport.status')).toBe(before.transport)
|
||||
expect(convenienceCallCounts()).toEqual({
|
||||
federationListNodes: before.federationListNodes,
|
||||
getTorAddress: before.getTorAddress,
|
||||
getNodeDid: before.getNodeDid,
|
||||
meshContactsList: before.meshContactsList,
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating past the short (10s) TTL revalidates the fast-moving groups while the near-static identity groups stay cached', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
const statusCallsAtMount = callCountFor('mesh.status')
|
||||
const transportCallsAtMount = callCountFor('transport.status')
|
||||
const onionCallsAtMount = vi.mocked(rpcClient.getTorAddress).mock.calls.length
|
||||
const didCallsAtMount = vi.mocked(rpcClient.getNodeDid).mock.calls.length
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(15000) // past the 10s reachability/transport TTL, well under the 300s identity TTL
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
// Peer/status/transport (reachability-class, 10s TTL) revalidate exactly once.
|
||||
expect(callCountFor('mesh.status')).toBe(statusCallsAtMount + 1)
|
||||
expect(callCountFor('transport.status')).toBe(transportCallsAtMount + 1)
|
||||
// This node's own onion/DID (300s TTL, near-static identity) are still fresh.
|
||||
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBe(onionCallsAtMount)
|
||||
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBe(didCallsAtMount)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('the previous graph/peer data stays rendered while a stale group revalidates (sticky-ready, no blank frame)', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Mesh Network')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(15000)
|
||||
await toggleTab(wrapper, true)
|
||||
// Before the revalidation resolves, prior content must still be present
|
||||
// (sticky-ready never regresses to a blank/loading state).
|
||||
expect(wrapper.text()).toContain('Mesh Network')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Mesh Network')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('one group rejecting leaves the other five unaffected and the fan-out still completes (deep-link/outbox callback runs)', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const meshStore = useMeshStore()
|
||||
const rejectingRefreshAll = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
// Bypass the store's own internal try/catch entirely so a genuine
|
||||
// rejection reaches useCachedResource's fetcher wrapper.
|
||||
meshStore.refreshAll = rejectingRefreshAll
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
AnimatedLogo: true, MeshMap: true, MeshBitcoinPanel: true, MeshDeadmanPanel: true,
|
||||
MeshDevicePanel: true, MeshAssistantPanel: true, HopVizModal: true, MediaLightbox: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(rejectingRefreshAll).toHaveBeenCalled()
|
||||
// The other five groups still ran to completion.
|
||||
expect(callCountFor('transport.status')).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.federationListNodes).mock.calls.length).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBeGreaterThan(0)
|
||||
expect(vi.mocked(rpcClient.meshContactsList).mock.calls.length).toBeGreaterThan(0)
|
||||
// The .then() callback after the fan-out (refreshOutboxCount -> mesh.outbox) still ran.
|
||||
expect(callCountFor('mesh.outbox')).toBeGreaterThan(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('every group carrying peer or self identity data declares persist:false; only the non-identity transport status may persist', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
|
||||
const readSnapshot = (key: string) => {
|
||||
try {
|
||||
return sessionStorage.getItem(`resource:${key}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
expect(readSnapshot('mesh.refresh-all')).toBeNull() // carries mesh.peers (DIDs/pubkeys)
|
||||
expect(readSnapshot('mesh.federation-nodes')).toBeNull() // DID/pubkey/onion
|
||||
expect(readSnapshot('mesh.self-onion')).toBeNull() // this node's own onion
|
||||
expect(readSnapshot('mesh.self-did')).toBeNull() // this node's own DID
|
||||
expect(readSnapshot('mesh.contacts')).toBeNull() // contact records/aliases
|
||||
expect(readSnapshot('mesh.transport-status')).not.toBeNull() // non-identity aggregate
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('every fetcher backing the six cache groups is registered with dedup:true', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
|
||||
// Restrict to the six cache groups' own methods — other rpcClient.call
|
||||
// traffic incidental to the fan-out (e.g. mesh.outbox, refreshed from the
|
||||
// post-fan-out .then()) isn't one of the cached groups and is out of
|
||||
// scope for this assertion.
|
||||
const cachedMethods = [
|
||||
'mesh.status', 'mesh.peers', 'mesh.messages', 'mesh.deadman-status',
|
||||
'mesh.block-headers', 'transport.status',
|
||||
]
|
||||
const dedupFlags = rpcCallMock.mock.calls
|
||||
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
|
||||
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
|
||||
expect(dedupFlags.length).toBe(cachedMethods.length)
|
||||
expect(dedupFlags.every(Boolean)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders RefreshIndicator wired to whether any of the six groups is refreshing', async () => {
|
||||
const wrapper = mountMeshHost()
|
||||
await flushPromises()
|
||||
|
||||
const indicator = wrapper.findComponent(RefreshIndicator)
|
||||
expect(indicator.exists()).toBe(true)
|
||||
// Idle once everything has settled.
|
||||
expect(indicator.props('state')).toBe('ready')
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
vi.advanceTimersByTime(15000)
|
||||
await toggleTab(wrapper, true)
|
||||
// Immediately after reactivation (before the revalidation resolves) the
|
||||
// indicator must be visible — peer reachability must never present a
|
||||
// frozen state as current without a visible refresh signal (T-02-13).
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@ -24,6 +24,7 @@
|
||||
}
|
||||
.mesh-header { justify-content: space-between; align-items: center; gap: 16px; flex-shrink: 0; }
|
||||
.mesh-header-left { flex: 1; }
|
||||
.mesh-title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.mesh-title { font-size: 1.5rem; font-weight: 700; color: rgba(255, 255, 255, 0.95); margin: 0; }
|
||||
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
|
||||
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user