Some checks failed
Demo images / Build & push demo images (push) Failing after 3m46s
Server.vue already had five of the seven load groups (network summary, FIPS summary, VPN peers, interfaces, Tor services) on useCachedResource from a pre-phase commit, but none declared an explicit ttlMs/persist (relying on the composable's 30s/persist:true defaults) and dedup:true was missing from several underlying RPC calls. loadDiskStatus was the one remaining plain uncached fetch, forced on every activation. - Explicit TTL per group (10s fast tier: network-summary/interfaces/ disk-status; 30s near-default: vpn-peers/tor-services; 60s near-static: fips-summary) - Explicit persist:false for server.vpn-peers (npub/peer identity) and server.tor-services (onion addresses) per T-02-01; other groups persist - New server.disk-status cached resource replaces the plain fetch; it now self-heals via the composable's own onActivated instead of an explicit every-entry call - dedup:true added to all underlying rpcClient calls, including the parameterless vpnStatus()/dnsStatus()/diskStatus() convenience methods - RefreshIndicator wired into a new minimal header row, driven by whether any of the six cache entries is refreshing - RESEARCH assumption A3 settled: read all seven loader bodies; none consumes another's result — the concurrent fan-out is correct as-is - Fixed a keepAliveLifecycle.test.ts assertion invalidated by the new 10s network-summary TTL (reactivation now also revalidates that resource, adding one more vpnStatus() call the test didn't previously account for) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
// 02-06 Task 1: caches the Server tab's seven load groups (network summary,
|
|
// FIPS summary, VPN peers, interfaces, Tor services — shared by
|
|
// checkTorStatus and loadTorServices — and disk status) behind keyed
|
|
// useCachedResource entries, with per-group TTL/persist decisions and the
|
|
// cold-load fan-out kept concurrent (RESEARCH A3 settled: none of the seven
|
|
// loaders consumes another's result — see 02-06-SUMMARY.md).
|
|
|
|
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 Server from '../Server.vue'
|
|
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
vi.mock('@/stores/app', () => ({
|
|
useAppStore: () => ({ packages: {} }),
|
|
}))
|
|
|
|
// Records every rpcClient.call({method}) invocation so tests can assert
|
|
// per-group call counts and concurrency without depending on real network
|
|
// I/O. vpnStatus/dnsStatus/diskStatus are separate convenience methods on
|
|
// rpcClient (not routed through call()), so they're mocked independently.
|
|
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
|
|
switch (method) {
|
|
case 'network.diagnostics':
|
|
return { tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' }
|
|
case 'router.list-forwards':
|
|
return { forwards: [] }
|
|
case 'network.list-interfaces':
|
|
return { interfaces: [] }
|
|
case 'tor.list-services':
|
|
return { services: [], tor_running: false }
|
|
case 'vpn.list-peers':
|
|
return { peers: [] }
|
|
case 'fips.status':
|
|
return { installed: false, service_active: false, key_present: false }
|
|
default:
|
|
return {}
|
|
}
|
|
})
|
|
|
|
const vpnStatusMock = vi.fn(async () => ({
|
|
connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24',
|
|
peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: true, configured_provider: 'wireguard',
|
|
}))
|
|
const dnsStatusMock = vi.fn(async () => ({
|
|
provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [],
|
|
}))
|
|
const diskStatusMock = vi.fn(async () => ({
|
|
used_bytes: 0, total_bytes: 0, free_bytes: 0, used_percent: 0, level: 'ok' as const,
|
|
}))
|
|
|
|
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),
|
|
dnsStatus: (...args: unknown[]) => (dnsStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
|
|
diskStatus: (...args: unknown[]) => (diskStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
|
|
},
|
|
}))
|
|
|
|
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
|
|
|
function mountServerHost() {
|
|
const Host = defineComponent({
|
|
setup() {
|
|
const show = ref(true)
|
|
return { show }
|
|
},
|
|
render() {
|
|
return h(KeepAlive, null, () =>
|
|
this.show ? h(Server, { key: 'server' }) : h(Other, { key: 'other' }),
|
|
)
|
|
},
|
|
})
|
|
return mount(Host, {
|
|
global: {
|
|
plugins: [createPinia()],
|
|
stubs: {
|
|
QuickActionsCard: true,
|
|
TorServicesCard: true,
|
|
ServerModals: true,
|
|
FipsNetworkCard: 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 mountServerHost>, show: boolean) {
|
|
;(wrapper.vm as unknown as { show: boolean }).show = show
|
|
await wrapper.vm.$nextTick()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
rpcCallMock.mockClear()
|
|
vpnStatusMock.mockClear()
|
|
dnsStatusMock.mockClear()
|
|
diskStatusMock.mockClear()
|
|
try {
|
|
sessionStorage.clear()
|
|
} catch { /* unavailable in some envs */ }
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('Server tab cache (Task 1): seven load groups', () => {
|
|
it('a cold load fires all seven groups concurrently — every RPC has already started before any microtask resolves', async () => {
|
|
const wrapper = mountServerHost()
|
|
|
|
// Synchronous check (no await yet): armServerEntryEffects/onMounted call
|
|
// all seven loaders without awaiting one before starting the next — a
|
|
// serialized chain could not have reached all seven RPCs yet here.
|
|
expect(callCountFor('network.diagnostics')).toBe(1)
|
|
expect(callCountFor('router.list-forwards')).toBe(1)
|
|
// vpnStatus fires twice on a fresh mount: once for network-summary's own
|
|
// fetch, once for the VPN poll's immediate first tick (armVpnPoll, a
|
|
// deliberate every-activation effect independent of this TTL cache).
|
|
expect(vpnStatusMock).toHaveBeenCalledTimes(2)
|
|
expect(dnsStatusMock).toHaveBeenCalledTimes(1)
|
|
expect(callCountFor('network.list-interfaces')).toBe(1)
|
|
expect(callCountFor('tor.list-services')).toBe(1)
|
|
expect(callCountFor('vpn.list-peers')).toBe(1)
|
|
expect(callCountFor('fips.status')).toBe(1)
|
|
expect(diskStatusMock).toHaveBeenCalledTimes(1)
|
|
|
|
await flushPromises()
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('reactivating inside every group\'s TTL issues zero additional RPCs across all seven groups', async () => {
|
|
const wrapper = mountServerHost()
|
|
await flushPromises()
|
|
const before = {
|
|
diag: callCountFor('network.diagnostics'),
|
|
fwd: callCountFor('router.list-forwards'),
|
|
iface: callCountFor('network.list-interfaces'),
|
|
tor: callCountFor('tor.list-services'),
|
|
vpnPeers: callCountFor('vpn.list-peers'),
|
|
fips: callCountFor('fips.status'),
|
|
vpnStatus: vpnStatusMock.mock.calls.length,
|
|
dns: dnsStatusMock.mock.calls.length,
|
|
disk: diskStatusMock.mock.calls.length,
|
|
}
|
|
|
|
await toggleTab(wrapper, false)
|
|
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
|
|
await toggleTab(wrapper, true)
|
|
await flushPromises()
|
|
|
|
expect(callCountFor('network.diagnostics')).toBe(before.diag)
|
|
expect(callCountFor('router.list-forwards')).toBe(before.fwd)
|
|
expect(callCountFor('network.list-interfaces')).toBe(before.iface)
|
|
expect(callCountFor('tor.list-services')).toBe(before.tor)
|
|
expect(callCountFor('vpn.list-peers')).toBe(before.vpnPeers)
|
|
expect(callCountFor('fips.status')).toBe(before.fips)
|
|
// dnsStatus is purely network-summary's own TTL-gated call — untouched.
|
|
expect(dnsStatusMock.mock.calls.length).toBe(before.dns)
|
|
// vpnStatus is the one exception: armVpnPoll's immediate first tick on
|
|
// reactivation fires regardless of network-summary's own TTL (a
|
|
// deliberate every-activation VPN-IP freshness effect from 02-04, not
|
|
// something this task's TTL/persist conversion changes).
|
|
expect(vpnStatusMock.mock.calls.length).toBe(before.vpnStatus + 1)
|
|
expect(diskStatusMock.mock.calls.length).toBe(before.disk)
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('reactivating past the short (10s) TTL revalidates the fast groups while the longer-TTL FIPS/Tor/VPN-peer groups stay cached', async () => {
|
|
const wrapper = mountServerHost()
|
|
await flushPromises()
|
|
const diagAtMount = callCountFor('network.diagnostics')
|
|
const ifaceAtMount = callCountFor('network.list-interfaces')
|
|
const diskAtMount = diskStatusMock.mock.calls.length
|
|
const fipsAtMount = callCountFor('fips.status')
|
|
const torAtMount = callCountFor('tor.list-services')
|
|
const vpnPeersAtMount = callCountFor('vpn.list-peers')
|
|
|
|
await toggleTab(wrapper, false)
|
|
// Past the 10s fast tier, well under the 30s Tor/VPN-peer tier and the
|
|
// 60s FIPS tier.
|
|
vi.advanceTimersByTime(12000)
|
|
await toggleTab(wrapper, true)
|
|
await flushPromises()
|
|
|
|
expect(callCountFor('network.diagnostics')).toBe(diagAtMount + 1)
|
|
expect(callCountFor('network.list-interfaces')).toBe(ifaceAtMount + 1)
|
|
expect(diskStatusMock.mock.calls.length).toBe(diskAtMount + 1)
|
|
// FIPS (60s), Tor services (30s) and VPN peers (30s) are still fresh.
|
|
expect(callCountFor('fips.status')).toBe(fipsAtMount)
|
|
expect(callCountFor('tor.list-services')).toBe(torAtMount)
|
|
expect(callCountFor('vpn.list-peers')).toBe(vpnPeersAtMount)
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('a rejected refresh in one group leaves the other six unaffected and keeps that group\'s last known data rendered', async () => {
|
|
// Mounted directly (no KeepAlive host) so the exposed loadNetworkData()
|
|
// method is reachable, matching ServerNetworkRefresh.test.ts's convention.
|
|
const wrapper = mount(Server, {
|
|
global: {
|
|
plugins: [createPinia()],
|
|
stubs: { QuickActionsCard: true, TorServicesCard: true, ServerModals: true, FipsNetworkCard: true },
|
|
},
|
|
})
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('Lab WiFi')
|
|
|
|
rpcCallMock.mockImplementationOnce(async ({ method }: { method: string }) => {
|
|
if (method === 'network.diagnostics') throw new Error('offline')
|
|
return {}
|
|
})
|
|
|
|
await (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
|
|
await flushPromises()
|
|
|
|
// Prior network data stays rendered (keep-last-value on error, D-07).
|
|
expect(wrapper.text()).toContain('Lab WiFi')
|
|
// Other groups' own data is untouched by the rejection.
|
|
expect(diskStatusMock).toHaveBeenCalled()
|
|
expect(callCountFor('fips.status')).toBeGreaterThan(0)
|
|
expect(callCountFor('vpn.list-peers')).toBeGreaterThan(0)
|
|
expect(callCountFor('network.list-interfaces')).toBeGreaterThan(0)
|
|
expect(callCountFor('tor.list-services')).toBeGreaterThan(0)
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('groups carrying VPN peer identity or Tor onion addresses declare persist:false; non-identity groups may persist', async () => {
|
|
const wrapper = mountServerHost()
|
|
await flushPromises()
|
|
|
|
const readSnapshot = (key: string) => {
|
|
try {
|
|
return sessionStorage.getItem(`resource:${key}`)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
expect(readSnapshot('server.vpn-peers')).toBeNull() // carries npub (peer identity)
|
|
expect(readSnapshot('server.tor-services')).toBeNull() // carries onion_address
|
|
expect(readSnapshot('server.network-summary')).not.toBeNull() // this node's own status
|
|
expect(readSnapshot('server.fips-summary')).not.toBeNull()
|
|
expect(readSnapshot('server.interfaces')).not.toBeNull()
|
|
expect(readSnapshot('server.disk-status')).not.toBeNull()
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('every fetcher backing the seven load groups is registered with dedup:true', async () => {
|
|
const wrapper = mountServerHost()
|
|
await flushPromises()
|
|
|
|
const cachedMethods = [
|
|
'network.diagnostics', 'router.list-forwards', 'network.list-interfaces',
|
|
'tor.list-services', 'vpn.list-peers', 'fips.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 seven groups is refreshing', async () => {
|
|
const wrapper = mountServerHost()
|
|
await flushPromises()
|
|
|
|
const indicator = wrapper.findComponent(RefreshIndicator)
|
|
expect(indicator.exists()).toBe(true)
|
|
expect(indicator.props('state')).toBe('ready')
|
|
|
|
await toggleTab(wrapper, false)
|
|
vi.advanceTimersByTime(12000)
|
|
await toggleTab(wrapper, true)
|
|
await wrapper.vm.$nextTick()
|
|
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
|
|
|
|
await flushPromises()
|
|
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
})
|