test(02-09): pin Server/Web5 KeepAlive round-trip survival via instance uid
Demo images / Build & push demo images (push) Successful in 3m41s

Task 2 (no-op branch, per plan): Task 1's evidence positively proved
Server.vue and Web5.vue's instances already survive tab round-trips —
the "remounts" reading was a probe artifact (02-FINDINGS.md), not a real
defect. No change to DashboardRouterView.vue, dashboardViewWrappers.ts,
keepAliveRoutes.ts or Server.vue's KeepAlive/lifecycle wiring.

Lands 4 regression tests in keepAliveLifecycle.test.ts using Vue's own
component-instance identity (vm.$.uid) instead of a CSS selector, so the
pin can't inherit the same generic-.view-container ambiguity Task 1 found:
round-trip identity for Server (Test 1) and Web5 + a second tab (Test 2),
include-list correctness (Test 3), and the LRU cap staying intact (Test 4).
All four pass immediately against the unmodified code — that pass is
itself the pin, per the plan's explicitly anticipated no-change path.

Full suite green (95 files / 778 tests), type-check clean, build succeeds.
keepAliveTabs.test.ts confirmed byte-for-byte unmodified and still green.
No deploy: nothing in neode-ui/src changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-31 05:50:55 -04:00
co-authored by Claude Fable 5
parent 550a2927b9
commit 3e3159fa95
2 changed files with 244 additions and 6 deletions
@@ -17,21 +17,40 @@ import { KeepAlive, defineComponent, h, onActivated, onBeforeUnmount, onDeactiva
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue'
import { KEEP_ALIVE_MAX, KEEP_ALIVE_PATHS, shouldKeepAlive } from '../keepAliveRoutes'
import { keepAliveIncludeNames, KEEP_WRAP_PREFIX } from '../dashboardViewWrappers'
import { rpcClient } from '@/api/rpc-client'
import Server from '@/views/Server.vue'
import Web5 from '@/views/web5/Web5.vue'
// Hoisted by vitest — must live at module scope, not inside a test body, or
// Server.vue's own module-level `import { rpcClient } from '@/api/rpc-client'`
// would already be bound to the real implementation by the time a test ran.
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
// Server.vue's/Web5.vue's own module-level `import { rpcClient } from
// '@/api/rpc-client'` would already be bound to the real implementation by
// the time a test ran. A Proxy fallback covers every rpcClient method either
// real view calls (Web5.vue's onMounted/armWeb5Live() reaches several beyond
// the four Server.vue needs) without having to enumerate them all by name.
vi.mock('@/api/rpc-client', () => {
const base: Record<string, unknown> = {
call: vi.fn().mockResolvedValue({}),
vpnStatus: vi.fn().mockResolvedValue({ connected: false }),
dnsStatus: vi.fn().mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false }),
diskStatus: vi.fn().mockResolvedValue({ encrypted: false, warnings: [] }),
},
}))
resolveDid: vi.fn().mockResolvedValue({}),
detectUsbDevices: vi.fn().mockResolvedValue({ devices: [] }),
getNodeDid: vi.fn().mockResolvedValue({}),
}
return {
rpcClient: new Proxy(base, {
get(target, prop: string) {
if (prop in target) return target[prop]
const fn = vi.fn().mockResolvedValue({})
target[prop] = fn
return fn
},
}),
}
})
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ packages: {} }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
describe('keepAliveLifecycle: activate/deactivate contract (synthetic consumer)', () => {
let mountCount: number
@@ -470,3 +489,184 @@ describe('keepAliveRoutes: widened registration set (02-04)', () => {
wrapper.unmount()
})
})
// 02-09 gap closure: Task 1 named the cause of the apparent "Server.vue
// genuinely remounts" gap as a proven probe-measurement artifact (see
// 02-FINDINGS.md's "## Server KeepAlive Root Cause (gap closure)" section)
// rather than a real KeepAlive/lifecycle defect — an authoritative,
// independent `document.elementFromPoint()` hit-test signal on the deployed
// archi-dev-box build directly contradicted the naive selector-match probe's
// "remounted" verdict for both Server.vue and Web5.vue, and a companion
// diagnostic found the ORIGINAL stamped root still connected and visible
// under a different (unpicked) DOM match. No source change to
// DashboardRouterView.vue / dashboardViewWrappers.ts / keepAliveRoutes.ts /
// Server.vue's KeepAlive/lifecycle wiring is made — these tests pin the
// architecture's CURRENT, unmodified behavior as the regression guard Task
// 2's `<behavior>` calls for, using Vue's own component-instance identity
// (`vm.$.uid`) rather than a CSS selector, which is exactly the class of
// signal that sidesteps the generic-`.view-container`-selector ambiguity
// Task 1 found responsible for the false "remounted" reading in the first
// place.
describe('keepAliveLifecycle: 02-09 gap closure — Server/Web5 round-trip survival, measured via component-instance identity (not a CSS selector)', () => {
function mountRealRouterView(startPath: string) {
const SettingsStub = defineComponent({ name: 'SettingsStub', render: () => h('div', 'settings') })
const routes: RouteRecordRaw[] = [
{ path: '/dashboard/server', name: 'server', component: Server },
{ path: '/dashboard/web5', name: 'web5', component: Web5 },
{ path: '/dashboard/settings', name: 'settings', component: SettingsStub },
]
const router = createRouter({ history: createMemoryHistory(), routes })
router.push(startPath)
return { router, routes }
}
it('Test 1 (the gap): a round-trip through /dashboard/server mounts the real Server.vue exactly once — the second arrival reactivates, not remounts', async () => {
const { router } = mountRealRouterView('/dashboard/server')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: {
plugins: [router, createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
await flushPromises()
const firstInstance = wrapper.findComponent(Server)
expect(firstInstance.exists()).toBe(true)
const firstUid = firstInstance.vm.$.uid
await router.push('/dashboard/settings')
await flushPromises()
await router.push('/dashboard/server')
await flushPromises()
const secondInstance = wrapper.findComponent(Server)
expect(secondInstance.exists()).toBe(true)
// Same instance uid == exactly one Server.vue instance ever existed for
// this round trip (a genuine remount would create a second, higher uid).
expect(secondInstance.vm.$.uid).toBe(firstUid)
wrapper.unmount()
})
it('Test 2 (no collateral damage): the same round-trip through Web5.vue (the other surface this gap closure re-examined) and a synthetic second tab both keep their instance/mount counts at 1', async () => {
let appsMountCount = 0
const AppsStubWithCounter = defineComponent({
name: 'AppsStubCounter',
setup() {
onMounted(() => { appsMountCount++ })
return () => h('div', 'apps')
},
})
const SettingsStub = defineComponent({ name: 'SettingsStub', render: () => h('div', 'settings') })
const routes: RouteRecordRaw[] = [
{ path: '/dashboard/web5', name: 'web5', component: Web5 },
{ path: '/dashboard/apps', name: 'apps', component: AppsStubWithCounter },
{ path: '/dashboard/settings', name: 'settings', component: SettingsStub },
]
const router = createRouter({ history: createMemoryHistory(), routes })
router.push('/dashboard/web5')
await router.isReady()
// Web5.vue's armWeb5Live() (its onActivated/onMounted arm function, the
// same idiom Server.vue's armServerEntryEffects() uses) calls exposed
// methods on child refs directly — a bare `stubs: { X: true }` auto-stub
// doesn't expose anything, so it throws under test. Real, minimal expose
// stubs (no-op bodies) let armWeb5Live() run exactly as it does against
// the real children, without needing their full implementations.
const ConnectedNodesStub = defineComponent({
name: 'Web5ConnectedNodesStub',
setup(_props, { expose }) {
expose({ loadPeers: () => {}, loadReceivedMessages: () => {}, loadConnectionRequests: () => {} })
return () => h('div')
},
})
const NodeVisibilityStub = defineComponent({
name: 'Web5NodeVisibilityStub',
setup(_props, { expose }) {
expose({ loadVisibility: () => {} })
return () => h('div')
},
})
const IdentitiesStub = defineComponent({
name: 'Web5IdentitiesStub',
setup(_props, { expose }) {
expose({ loadIdentities: () => {} })
return () => h('div')
},
})
const NostrRelaysStub = defineComponent({
name: 'Web5NostrRelaysStub',
setup(_props, { expose }) {
expose({ loadNostrRelays: () => {}, openRelaysModal: () => {} })
return () => h('div')
},
})
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: {
plugins: [router, createPinia()],
stubs: {
Web5QuickActions: true,
Web5ConnectedNodes: ConnectedNodesStub,
Web5NodeVisibility: NodeVisibilityStub,
Web5Identities: IdentitiesStub,
Web5NostrRelays: NostrRelaysStub,
Web5Monitoring: true,
Web5Federation: true,
},
},
})
await flushPromises()
const firstWeb5 = wrapper.findComponent(Web5)
expect(firstWeb5.exists()).toBe(true)
const firstWeb5Uid = firstWeb5.vm.$.uid
await router.push('/dashboard/apps')
await flushPromises()
expect(appsMountCount).toBe(1)
await router.push('/dashboard/settings')
await flushPromises()
await router.push('/dashboard/web5')
await flushPromises()
expect(wrapper.findComponent(Web5).vm.$.uid).toBe(firstWeb5Uid) // Web5 reactivated, not remounted
await router.push('/dashboard/settings')
await flushPromises()
await router.push('/dashboard/apps')
await flushPromises()
expect(appsMountCount).toBe(1) // still just the one mount — Apps reactivated too
wrapper.unmount()
})
it('Test 3 (registration is really the include list): every keepAliveIncludeNames() entry is a KeepWrap:<path> name for a registered path, contains no comma (Vue\'s own include-matching splits only on commas), and /dashboard/server\'s wrapper name is among them', () => {
const names = keepAliveIncludeNames()
expect(names.length).toBe(KEEP_ALIVE_PATHS.size)
for (const name of names) {
expect(name.startsWith(KEEP_WRAP_PREFIX)).toBe(true)
const path = name.slice(KEEP_WRAP_PREFIX.length)
expect(KEEP_ALIVE_PATHS.has(path)).toBe(true)
expect(name.includes(',')).toBe(false)
}
expect(names).toContain(`${KEEP_WRAP_PREFIX}/dashboard/server`)
expect(names).toContain(`${KEEP_WRAP_PREFIX}/dashboard/web5`)
})
it('Test 4 (the bound stays bound): shouldKeepAlive/KEEP_ALIVE_MAX are unchanged by this gap closure — Server and Web5 are still registered, still evictable, and the cap is still 6', () => {
expect(shouldKeepAlive({ path: '/dashboard/server' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/web5' })).toBe(true)
expect(KEEP_ALIVE_MAX).toBe(6)
})
})