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
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user