test(02-09): pin Server/Web5 KeepAlive round-trip survival via instance uid
Demo images / Build & push demo images (push) Successful in 3m41s
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:
co-authored by
Claude Fable 5
parent
550a2927b9
commit
3e3159fa95
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user