From 8d9fad1749fd127664bf873d02d435b2ad5f98c0 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 29 Sep 2026 15:37:05 -0400 Subject: [PATCH] Require Bitcoin version and pruning selection from the App Store --- docs/repair-release-20260929.md | 25 +++++++++ neode-ui/src/views/Marketplace.vue | 49 ++++++++++++++--- .../__tests__/MarketplaceRefresh.test.ts | 54 ++++++++++++++++++- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/docs/repair-release-20260929.md b/docs/repair-release-20260929.md index 02c1a42d..87eac119 100644 --- a/docs/repair-release-20260929.md +++ b/docs/repair-release-20260929.md @@ -189,3 +189,28 @@ live wallet data, host process IDs, or external network. Bitcoin/LND start times remained unchanged during isolated execution. Production helpers are unchanged; the namespace-specific command behavior is compiled only into unit tests. Release and ISO gates now use the isolated runner. + +### Final backend deployment and App Store follow-up — 19:36 UTC + +Full release harness passed: static/catalog checks, frontend type-check and +1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed, +four existing ignored). Final optimized backend built successfully; SHA256 +16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7. +Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC +and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe. +Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged. + +Actual desktop pruning screenshot exposed horizontal overflow; moved the +explanation below the app header. The App Store uses Marketplace.vue, a separate +install path from Discover.vue. Its first Install button bypassed the version +modal. The browser check therefore sent an unintended Knots install request at +19:28 UTC. Core remained running, no Knots container was created, and the full +chain was not pruned. Removed only the newly created Knots installed-app record +and newly created version config; preserved root-only rollback copies. + +Marketplace now uses the shared version/pruning modal. Added integration tests +for both Core and Knots: no install request until confirmation, selected version +and pruning forwarded, cancellation sends no install request. Four Marketplace +tests pass (three new plus existing refresh check). Further browser checks block +package.install requests at their network boundary. Final frontend rebuild and +post-fix live checks remain pending. Final paid-file follow-up is still pending. diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue index 5b6dffc4..6b6da47d 100644 --- a/neode-ui/src/views/Marketplace.vue +++ b/neode-ui/src/views/Marketplace.vue @@ -137,7 +137,7 @@ :tier-label="getAppTier(app.id)" :install-blocked-reason="installBlockedReason(app.id)" @view="viewAppDetails" - @install="app.source === 'local' ? installApp(app) : installCommunityApp(app)" + @install="handleInstall(app)" @launch="launchInstalledApp" /> @@ -153,7 +153,13 @@ - + @@ -175,11 +181,13 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs' import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout' import { useCachedResource } from '@/composables/useCachedResource' import RefreshIndicator from '@/components/RefreshIndicator.vue' +import InstallVersionModal from '@/components/InstallVersionModal.vue' import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories' import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue' import { type MarketplaceApp, INSTALLED_ALIASES, + MULTI_VERSION_APP_IDS, getAppTier, categorizeCommunityApp, getCuratedAppList, @@ -206,6 +214,8 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS) // Installation state — uses global store so it persists across navigation const installingApps = server.installingApps +const showInstallModal = ref(false) +const installModalApp = ref(null) const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX' function installToast(app: MarketplaceApp) { @@ -518,7 +528,34 @@ function failInstall(app: MarketplaceApp, err: unknown) { trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000) } -async function installApp(app: MarketplaceApp) { +function handleInstall(app: MarketplaceApp) { + if (installingApps.has(app.id) || isInstalled(app.id)) return + const blocked = installBlockedReason(app.id) + if (blocked) { + toast.error(blocked) + return + } + if (MULTI_VERSION_APP_IDS.has(app.id)) { + installModalApp.value = app + showInstallModal.value = true + return + } + startInstall(app) +} + +function startInstall(app: MarketplaceApp, version?: string, prune?: boolean) { + if (app.source === 'local') void installApp(app, version, prune) + else void installCommunityApp(app, version, prune) +} + +function onInstallModalConfirm(version: string, prune?: boolean) { + const app = installModalApp.value + showInstallModal.value = false + installModalApp.value = null + if (app) startInstall(app, version, prune) +} + +async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) { if (installingApps.has(app.id) || isInstalled(app.id)) return const blocked = installBlockedReason(app.id) if (blocked) { @@ -536,7 +573,7 @@ async function installApp(app: MarketplaceApp) { const installUrl = app.url || app.manifestUrl || app.s9pkUrl await rpcClient.call({ method: 'package.install', - params: { id: app.id, url: installUrl, version: app.version }, + params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }, timeout: 600000, }) } catch (err) { @@ -545,7 +582,7 @@ async function installApp(app: MarketplaceApp) { } } -async function installCommunityApp(app: MarketplaceApp) { +async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) { if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return const blocked = installBlockedReason(app.id) if (blocked) { @@ -558,7 +595,7 @@ async function installCommunityApp(app: MarketplaceApp) { installToast(app) try { - const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: app.version } + const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) } if (app.containerConfig) installParams.containerConfig = app.containerConfig await rpcClient.call({ method: 'package.install', diff --git a/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts b/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts index 35146ca3..730d0af6 100644 --- a/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts +++ b/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts @@ -2,6 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Marketplace from '../Marketplace.vue' +import { rpcClient } from '@/api/rpc-client' +import InstallVersionModal from '@/components/InstallVersionModal.vue' +import MarketplaceAppCard from '../marketplace/MarketplaceAppCard.vue' // Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for // mounting a view directly with its heavier deps mocked at the module @@ -44,22 +47,37 @@ vi.mock('@/composables/useMarketplaceApp', () => ({ })) vi.mock('@/composables/useToast', () => ({ - useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }), + useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }), })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }), + getPackageVersions: vi.fn(), }, })) +vi.mock('../discover/curatedApps', () => ({ + fetchAppCatalog: vi.fn().mockResolvedValue({ + apps: ['bitcoin-core', 'bitcoin-knots'].map(id => ({ + id, title: id, version: '29.0', description: 'Bitcoin node', + dockerImage: `registry.example/${id}:29.0`, source: 'community', + })), + }), +})) + describe('Marketplace tracer tab: background refresh failure (D-07)', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))) routerPushMock.mockClear() toastErrorMock.mockClear() toastInfoMock.mockClear() + vi.mocked(rpcClient.call).mockReset() + vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({ + supportsVersions: true, default: '29.0', bitcoinPrune: false, + versions: [{ version: '29.0', default: true, deprecated: false, eol: null }], + } as Awaited>) }) afterEach(() => { @@ -89,4 +107,38 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => { wrapper.unmount() }) + + it.each(['bitcoin-core', 'bitcoin-knots'])('requires the version modal before installing %s and forwards pruning', async (id) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) })) + const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } }) + await flushPromises() + const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === id)! + expect(card.exists()).toBe(true) + card.vm.$emit('install', card.props('app')) + await flushPromises() + const installs = () => vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install') + expect(installs()).toHaveLength(0) + const modal = wrapper.findComponent(InstallVersionModal) + expect(modal.props('show')).toBe(true) + await modal.get('input[type="checkbox"]').setValue(true) + await modal.get('button.glass-button-warning').trigger('click') + await flushPromises() + expect(installs()).toHaveLength(1) + expect(installs()[0][0].params).toMatchObject({ id, version: '29.0', prune: true }) + expect(modal.props('show')).toBe(false) + wrapper.unmount() + }) + + it('cancels Bitcoin selection without sending an installation request', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) })) + const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } }) + await flushPromises() + const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === 'bitcoin-knots')! + card.vm.$emit('install', card.props('app')) + await flushPromises() + wrapper.findComponent(InstallVersionModal).vm.$emit('close') + await flushPromises() + expect(vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')).toHaveLength(0) + wrapper.unmount() + }) })