feat(02-03): app detail screens paint from cache on repeat visits
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m26s
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m26s
- AppDetails.vue: bitcoin-sync and credentials converted to keyed useCachedResource (app-details:bitcoin-sync:<id>, app-details:credentials:<id>), each keyed by the route app id so two items never collide. Credentials is persist:false (credential material, D-08/T-02-01). Both loaders stay fire-and-forget from onMounted (already parallel — not touched). Stop/ restart/uninstall now invalidate() the credentials resource so a stale healthy state can't outlive a destructive action (T-02-12). - MarketplaceAppDetails.vue: the one RPC call in this view that isn't a measurement artifact of the Home-tab-transit confound (package.versions) is now a keyed useCachedResource (app-details:versions:<id>, 120s TTL — near-static catalog metadata). getCurrentApp() is a sync store read and the bitcoin-prune check is a plain fetch(), neither need conversion. - secondaryScreenCache.test.ts: covers per-item isolation (rendered content, not just call counts), TTL-gated no-refetch, TTL-lapse revalidation, and keep-last-value on a rejected refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f44b8ac78c
commit
7c6c487a03
@ -83,6 +83,7 @@ import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
@ -162,45 +163,69 @@ const gatewayState = computed(() => {
|
||||
})
|
||||
|
||||
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
|
||||
const bitcoinSyncPercent = ref(0)
|
||||
const bitcoinBlockHeight = ref(0)
|
||||
|
||||
// Keyed per app id (D-04): AppDetails is never instance-cached (no
|
||||
// KeepAlive), but the route's `:key="route.path"` (DashboardRouterView.vue)
|
||||
// means an id change always fully remounts this component, so the key can be
|
||||
// computed once at setup time rather than re-derived via a watch.
|
||||
const bitcoinSyncResource = useCachedResource<{ block_height: number; sync_progress: number }>({
|
||||
key: `app-details:bitcoin-sync:${appId.value}`,
|
||||
fetcher: (signal) => rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
signal,
|
||||
dedup: true,
|
||||
timeout: 5000,
|
||||
}),
|
||||
ttlMs: 30_000, // install/health-state-shaped data, per plan default
|
||||
immediate: false, // kicked from onMounted, gated on needsBitcoinSync
|
||||
})
|
||||
const bitcoinSyncPercent = computed(() => (bitcoinSyncResource.data.value?.sync_progress ?? 0) * 100)
|
||||
const bitcoinBlockHeight = computed(() => bitcoinSyncResource.data.value?.block_height ?? 0)
|
||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||
const credentials = ref<AppCredentialsResponse | null>(null)
|
||||
const credentialsLoading = ref(false)
|
||||
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||
|
||||
async function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 5000,
|
||||
})
|
||||
bitcoinSyncPercent.value = (btc.sync_progress ?? 0) * 100
|
||||
bitcoinBlockHeight.value = btc.block_height ?? 0
|
||||
} catch {
|
||||
bitcoinSyncPercent.value = 0
|
||||
bitcoinBlockHeight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
credentialsLoading.value = true
|
||||
try {
|
||||
// Credential material — memory-only (persist: false), never written to
|
||||
// sessionStorage (D-08 / T-02-01).
|
||||
const credentialsResource = useCachedResource<AppCredentialsResponse | null>({
|
||||
key: `app-details:credentials:${appId.value}`,
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: packageKey.value },
|
||||
signal,
|
||||
dedup: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
credentials.value = resolveAppCredentials(packageKey.value, result)
|
||||
} catch {
|
||||
credentials.value = resolveAppCredentials(packageKey.value, null)
|
||||
} finally {
|
||||
credentialsLoading.value = false
|
||||
return resolveAppCredentials(packageKey.value, result)
|
||||
},
|
||||
ttlMs: 30_000,
|
||||
persist: false,
|
||||
immediate: false,
|
||||
})
|
||||
const credentials = computed(() => credentialsResource.data.value ?? resolveAppCredentials(packageKey.value, null))
|
||||
const credentialsLoading = computed(() => credentialsResource.loadState.value === 'loading')
|
||||
|
||||
// refresh() is unconditional (force-fetch); only call it when the cached
|
||||
// entry is missing or past its TTL, so a repeat open inside the TTL paints
|
||||
// from cache with no new RPC.
|
||||
function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
if (bitcoinSyncResource.data.value === null || bitcoinSyncResource.isStale.value) {
|
||||
void bitcoinSyncResource.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
if (credentialsResource.data.value === null || credentialsResource.isStale.value) {
|
||||
void credentialsResource.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||
|
||||
// Both loaders are independent (bitcoin sync state vs. this app's
|
||||
// credentials) and were already fire-and-forget here before this plan — do
|
||||
// not "fix" this into an awaited chain, it is already effectively parallel.
|
||||
onMounted(() => {
|
||||
loadBitcoinSync()
|
||||
loadCredentials()
|
||||
@ -301,6 +326,9 @@ async function stopApp() {
|
||||
pendingAction.value = 'stop'
|
||||
try {
|
||||
await store.stopPackage(appId.value)
|
||||
// Stopping the app can take its admin credentials offline — invalidate
|
||||
// rather than show a stale "healthy" credentials card (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
@ -312,6 +340,9 @@ async function restartApp() {
|
||||
pendingAction.value = 'restart'
|
||||
try {
|
||||
await store.restartPackage(appId.value)
|
||||
// A restart can rotate credentials/admin URLs — invalidate so the next
|
||||
// read is fresh rather than the pre-restart cache (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
@ -340,6 +371,10 @@ async function confirmUninstall(deleteAppData: boolean) {
|
||||
pendingAction.value = 'uninstall'
|
||||
try {
|
||||
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
|
||||
// Invalidate before navigating away — the app no longer exists, so its
|
||||
// cached credentials must not be replayed if this screen is reopened
|
||||
// before the TTL lapses (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
|
||||
@ -380,6 +380,8 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import type { PackageVersionsResponse } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
@ -411,6 +413,26 @@ const electrumxArchiveWarning = 'You need a full archival bitcoin node before do
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
// Keyed per marketplace app id (D-04). Catalog version metadata is
|
||||
// near-static, so this can take a longer TTL than the 30s default; it holds
|
||||
// nothing credential/DID/wallet/tx-history-shaped so persist stays default
|
||||
// (true). Note per 02-FINDINGS.md: this is the only one of MarketplaceAppDetails's
|
||||
// captured RPC calls that isn't confounded by the Home-tab-transit artifact —
|
||||
// getCurrentApp() is a synchronous store read (no RPC) and the bitcoin-prune
|
||||
// check below is a plain fetch(), not an rpcClient call.
|
||||
const versionsResource = useCachedResource<PackageVersionsResponse>({
|
||||
key: `app-details:versions:${appId.value}`,
|
||||
fetcher: (signal) => rpcClient.call<PackageVersionsResponse>({
|
||||
method: 'package.versions',
|
||||
params: { id: appId.value },
|
||||
signal,
|
||||
dedup: true,
|
||||
timeout: 15000,
|
||||
}),
|
||||
ttlMs: 120_000,
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
// Web-only apps (no container, just a URL) — always treated as "installed"
|
||||
const isWebOnly = computed(() => {
|
||||
return !!(app.value?.webUrl && !app.value?.dockerImage)
|
||||
@ -550,17 +572,20 @@ onMounted(() => {
|
||||
|
||||
// Fetch the catalog's selectable versions so the install panel can offer a
|
||||
// choice (latest pre-selected). Best-effort: on any failure the selector stays
|
||||
// hidden and install proceeds at the catalog default.
|
||||
// hidden and install proceeds at the catalog default. Only refetches when the
|
||||
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
||||
// paints from cache with no new RPC.
|
||||
async function loadInstallVersions() {
|
||||
installVersions.value = []
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(appId.value)
|
||||
if (!info.supportsVersions || info.versions.length < 2) return
|
||||
installVersions.value = info.versions
|
||||
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] loadInstallVersions failed:', err)
|
||||
if (versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||
await versionsResource.refresh()
|
||||
}
|
||||
const info = versionsResource.data.value
|
||||
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
||||
installVersions.value = []
|
||||
return
|
||||
}
|
||||
installVersions.value = info.versions
|
||||
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
|
||||
}
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
|
||||
328
neode-ui/src/views/__tests__/secondaryScreenCache.test.ts
Normal file
328
neode-ui/src/views/__tests__/secondaryScreenCache.test.ts
Normal file
@ -0,0 +1,328 @@
|
||||
// Per-item keyed useCachedResource conversions for secondary screens (D-04,
|
||||
// plan 02-03). Pins: instant repeat-open from cache, no new RPC inside the
|
||||
// TTL, per-item key isolation (rendered content, not just call counts),
|
||||
// TTL-lapse revalidation, and keep-last-value on a rejected background
|
||||
// refresh (D-07).
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, type Pinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppDetails from '../AppDetails.vue'
|
||||
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
getPackageVersions: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
let currentRouteParams: Record<string, string> = {}
|
||||
let currentRouteQuery: Record<string, string> = {}
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(() => Promise.resolve()), replace: vi.fn() }),
|
||||
useRoute: () => ({ params: currentRouteParams, query: currentRouteQuery }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
let currentPackages: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
packages: currentPackages,
|
||||
startPackage: vi.fn().mockResolvedValue(undefined),
|
||||
stopPackage: vi.fn().mockResolvedValue(undefined),
|
||||
restartPackage: vi.fn().mockResolvedValue(undefined),
|
||||
updatePackage: vi.fn().mockResolvedValue(undefined),
|
||||
uninstallPackage: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMarketplaceApp', () => ({
|
||||
useMarketplaceApp: () => ({ getCurrentApp: () => currentMarketplaceApp }),
|
||||
}))
|
||||
|
||||
let currentMarketplaceApp: Record<string, unknown> | null = null
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePkg(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
manifest: { id: 'pkg', version: '1.0.0', title: 'Test App' },
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
installed: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// A single Pinia instance is shared across the unmount/remount pairs within
|
||||
// one test — matching production, where the resources store's in-memory
|
||||
// entries Map is a singleton that survives navigating away from and back to
|
||||
// a secondary screen (only a full page reload, or clearAll() on logout,
|
||||
// resets it). Creating a fresh Pinia per mount would wipe persist:false
|
||||
// entries (credentials) between "visits" and falsely fail the cache-hit
|
||||
// assertions below.
|
||||
function mountAppDetails(id: string, packages: Record<string, unknown>, pinia: Pinia) {
|
||||
currentRouteParams = { id }
|
||||
currentRouteQuery = {}
|
||||
currentPackages = packages
|
||||
return mount(AppDetails, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { AppHeroSection: true, AppContentSection: true, LndSeedBackup: true, AppsUninstallModal: true },
|
||||
mocks: { $ver: (v: string) => v },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppDetails.vue — per-item cached resources (bitcoin sync + credentials)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
|
||||
id: 'x', supportsVersions: false, default: null, installedVersion: null,
|
||||
pinnedVersion: null, autoUpdate: false, versions: [],
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function credentialsHandler(byId: Record<string, { label: string; value: string }>) {
|
||||
return vi.fn((req: { method: string; params?: { app_id?: string } }) => {
|
||||
if (req.method === 'package.credentials') {
|
||||
const appId = req.params?.app_id ?? ''
|
||||
const cred = byId[appId]
|
||||
return Promise.resolve(cred ? { credentials: [cred] } : { credentials: [] })
|
||||
}
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
}
|
||||
|
||||
it('repeat mount for the same app id inside the TTL issues exactly one credentials fetch total', async () => {
|
||||
const pinia = createPinia()
|
||||
const handler = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
|
||||
vi.mocked(rpcClient.call).mockImplementation(handler as never)
|
||||
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
|
||||
expect(credCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('mounting for app id alpha then beta issues two credentials fetches and never renders alpha data for beta', async () => {
|
||||
const pinia = createPinia()
|
||||
const handler = credentialsHandler({
|
||||
'app-alpha': { label: 'Password', value: 'alpha-secret' },
|
||||
'app-beta': { label: 'Password', value: 'beta-secret' },
|
||||
})
|
||||
vi.mocked(rpcClient.call).mockImplementation(handler as never)
|
||||
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
expect(w1.text()).toContain('alpha-secret')
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountAppDetails('app-beta', { 'app-beta': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
expect(w2.text()).toContain('beta-secret')
|
||||
expect(w2.text()).not.toContain('alpha-secret')
|
||||
w2.unmount()
|
||||
|
||||
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
|
||||
expect(credCalls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
|
||||
const pinia = createPinia()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
let credentialCallCount = 0
|
||||
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'package.credentials') {
|
||||
credentialCallCount++
|
||||
return Promise.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
|
||||
}
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
vi.advanceTimersByTime(31_000) // past the 30s TTL
|
||||
|
||||
// A deferred second fetch lets us observe the cached value on-screen
|
||||
// before the TTL-triggered revalidate resolves.
|
||||
const stalled = deferred<{ credentials: { label: string; value: string }[] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'package.credentials') {
|
||||
credentialCallCount++
|
||||
return stalled.promise
|
||||
}
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await w2.vm.$nextTick()
|
||||
// Cached value renders before the (TTL-triggered) revalidate resolves.
|
||||
expect(w2.text()).toContain('alpha-secret')
|
||||
|
||||
stalled.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(credentialCallCount).toBe(2)
|
||||
})
|
||||
|
||||
it('a rejected background refresh keeps the previously rendered credentials on screen', async () => {
|
||||
const pinia = createPinia()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const good = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
|
||||
vi.mocked(rpcClient.call).mockImplementation(good as never)
|
||||
|
||||
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
expect(w1.text()).toContain('alpha-secret')
|
||||
w1.unmount()
|
||||
|
||||
vi.advanceTimersByTime(31_000)
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'package.credentials') return Promise.reject(new Error('offline'))
|
||||
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
|
||||
await flushPromises()
|
||||
// Keep-last-value (D-07): the in-memory cache still shows the previous
|
||||
// credential even though the revalidate failed.
|
||||
expect(w2.text()).toContain('alpha-secret')
|
||||
w2.unmount()
|
||||
})
|
||||
|
||||
it('bitcoin-sync and credentials fetchers for a single mount are issued concurrently, not one after another', async () => {
|
||||
const pinia = createPinia()
|
||||
const bitcoinDeferred = deferred<{ block_height: number; sync_progress: number }>()
|
||||
const credsDeferred = deferred<{ credentials: { label: string; value: string }[] }>()
|
||||
const calledMethods: string[] = []
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
calledMethods.push(req.method)
|
||||
if (req.method === 'bitcoin.getinfo') return bitcoinDeferred.promise
|
||||
if (req.method === 'package.credentials') return credsDeferred.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
// 'mempool-electrs' is in BITCOIN_DEPENDENT_APPS and maps to itself via
|
||||
// ROUTE_TO_PACKAGE_KEY, so this id exercises the bitcoin-sync resource.
|
||||
const w = mountAppDetails('mempool-electrs', { 'mempool-electrs': makePkg() }, pinia)
|
||||
await w.vm.$nextTick()
|
||||
|
||||
// Both fetchers must already be in flight before either resolves —
|
||||
// proof neither loader awaited the other.
|
||||
expect(calledMethods).toContain('bitcoin.getinfo')
|
||||
expect(calledMethods).toContain('package.credentials')
|
||||
|
||||
bitcoinDeferred.resolve({ block_height: 800000, sync_progress: 1 })
|
||||
credsDeferred.resolve({ credentials: [] })
|
||||
await flushPromises()
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarketplaceAppDetails.vue — per-item cached catalog versions', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
currentRouteQuery = {}
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountMarketplaceDetails(id: string, app: Record<string, unknown> | null) {
|
||||
currentRouteParams = { id }
|
||||
currentRouteQuery = {}
|
||||
currentMarketplaceApp = app
|
||||
return mount(MarketplaceAppDetails, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
mocks: { $ver: (v: string) => v },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function makeApp(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
title: `App ${id}`,
|
||||
description: 'desc',
|
||||
version: '1.0.0',
|
||||
dockerImage: `registry/${id}:latest`,
|
||||
screenshots: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('repeat mount for the same marketplace app id inside the TTL issues exactly one package.versions fetch', async () => {
|
||||
const calls: string[] = []
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
|
||||
calls.push(req.method)
|
||||
if (req.method === 'package.versions') {
|
||||
return Promise.resolve({ id: 'demo-app', supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w1 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(calls.filter((m) => m === 'package.versions').length).toBe(1)
|
||||
})
|
||||
|
||||
it('mounting for marketplace app id alpha then beta each issue their own versions fetch (distinct per-item keys)', async () => {
|
||||
const requestedIds: string[] = []
|
||||
vi.mocked(rpcClient.call).mockImplementation((req: { method: string; params?: { id?: string } }) => {
|
||||
if (req.method === 'package.versions') {
|
||||
requestedIds.push(req.params?.id ?? '')
|
||||
return Promise.resolve({ id: req.params?.id, supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const w1 = mountMarketplaceDetails('mkt-alpha', makeApp('mkt-alpha'))
|
||||
await flushPromises()
|
||||
w1.unmount()
|
||||
|
||||
const w2 = mountMarketplaceDetails('mkt-beta', makeApp('mkt-beta'))
|
||||
await flushPromises()
|
||||
w2.unmount()
|
||||
|
||||
expect(requestedIds).toEqual(['mkt-alpha', 'mkt-beta'])
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user