diff --git a/.planning/phases/02-ui-performance/02-FINDINGS.md b/.planning/phases/02-ui-performance/02-FINDINGS.md index ac1fb4e9..2e80773d 100644 --- a/.planning/phases/02-ui-performance/02-FINDINGS.md +++ b/.planning/phases/02-ui-performance/02-FINDINGS.md @@ -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 (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. + +### 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:` 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. diff --git a/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts b/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts index 5c006122..0ec9942d 100644 --- a/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts +++ b/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts @@ -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 = { 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 `` 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: 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) + }) +})