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
@@ -593,3 +593,41 @@ selector ambiguity entirely by using Vue's own component tree instead of a CSS s
permanent regression pin, confirms they pass immediately against the CURRENT, unmodified code permanent regression pin, confirms they pass immediately against the CURRENT, unmodified code
(itself the proof), and updates this section with the pre-existing-code pass observation. No (itself the proof), and updates this section with the pre-existing-code pass observation. No
build/deploy/redeploy step is needed since nothing in `neode-ui/src` changes. build/deploy/redeploy step is needed since nothing in `neode-ui/src` changes.
### Task 2 outcome (no-op branch, per this plan's own explicitly anticipated path)
Landed four new tests in `keepAliveLifecycle.test.ts`'s new `02-09 gap closure` describe block,
measuring component-instance identity via `vm.$.uid` (Vue's own internal per-instance id) rather
than any CSS selector:
- **Test 1 (the gap):** mounts the REAL `DashboardRouterView` + REAL `Server.vue` at
`/dashboard/server`, away-hops to a synthetic `/dashboard/settings`, returns, and asserts
`findComponent(Server).vm.$.uid` is IDENTICAL before and after. **Passed immediately, first
try, against the unmodified code** — no RED phase was possible or expected, because this is
the no-change branch: the plan's own instruction ("this branch is only available on positive
proof of survival... land Tests 1-4, they will pass immediately, which is itself the pin")
describes exactly this outcome. A failing-before-fix observation does not exist here because
there was never a fix.
- **Test 2 (no collateral damage):** the same round trip through the REAL `Web5.vue` (the other
surface this investigation implicates) plus a synthetic second tab, both keeping their
instance-uid / mount-count at exactly 1 across the round trip. **Passed immediately.**
- **Test 3 (registration is really the include list):** every `keepAliveIncludeNames()` entry is
a `KeepWrap:<path>` name for a registered path, contains no comma, and `/dashboard/server`'s and
`/dashboard/web5`'s wrapper names are both present. **Passed** (already true, unconditionally,
from the existing derivation in `dashboardViewWrappers.ts`).
- **Test 4 (the bound stays bound):** `shouldKeepAlive` still registers Server/Web5 and
`KEEP_ALIVE_MAX` is still 6, unchanged. **Passed** — the pre-existing 02-04 eviction test in the
same file (`visiting more distinct registered tabs than KEEP_ALIVE_MAX...`) is untouched and
still green, confirming the cap itself was never in question.
**Verification run:** full `npm test`**95 test files / 778 tests, all green** (no regressions
introduced by the new mocks added to this file's `vi.mock('@/api/rpc-client', ...)` Proxy
fallback and new `vi.mock('vue-i18n', ...)`, both additive). `npm run type-check` — clean.
`npm run build` — succeeds (`✓ built in 24.62s`); no bundle-content grep was performed because,
per the no-change branch, nothing in `neode-ui/src` differs from the last-deployed build, so
there is no new string to look for and no reason to redeploy. `keepAliveTabs.test.ts` — confirmed
byte-for-byte unmodified (`git diff --stat` empty) and still green (6/6 tests passing).
**No deploy performed.** D-15's dev-pair-only rule is satisfied vacuously: archi-dev-box already
runs the build this investigation probed against (unchanged), and archy-x250-dev's status is
unaffected either way by a change that never happened.
@@ -17,21 +17,40 @@ import { KeepAlive, defineComponent, h, onActivated, onBeforeUnmount, onDeactiva
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue' import DashboardRouterView from '../DashboardRouterView.vue'
import { KEEP_ALIVE_MAX, KEEP_ALIVE_PATHS, shouldKeepAlive } from '../keepAliveRoutes' import { KEEP_ALIVE_MAX, KEEP_ALIVE_PATHS, shouldKeepAlive } from '../keepAliveRoutes'
import { keepAliveIncludeNames, KEEP_WRAP_PREFIX } from '../dashboardViewWrappers'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import Server from '@/views/Server.vue' 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 // 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'` // Server.vue's/Web5.vue's own module-level `import { rpcClient } from
// would already be bound to the real implementation by the time a test ran. // '@/api/rpc-client'` would already be bound to the real implementation by
vi.mock('@/api/rpc-client', () => ({ // the time a test ran. A Proxy fallback covers every rpcClient method either
rpcClient: { // 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({}), call: vi.fn().mockResolvedValue({}),
vpnStatus: vi.fn().mockResolvedValue({ connected: false }), vpnStatus: vi.fn().mockResolvedValue({ connected: false }),
dnsStatus: vi.fn().mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false }), dnsStatus: vi.fn().mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false }),
diskStatus: vi.fn().mockResolvedValue({ encrypted: false, warnings: [] }), 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('@/stores/app', () => ({ useAppStore: () => ({ packages: {} }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
describe('keepAliveLifecycle: activate/deactivate contract (synthetic consumer)', () => { describe('keepAliveLifecycle: activate/deactivate contract (synthetic consumer)', () => {
let mountCount: number let mountCount: number
@@ -470,3 +489,184 @@ describe('keepAliveRoutes: widened registration set (02-04)', () => {
wrapper.unmount() 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)
})
})