diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue
index 6e9cf8a22d944..5bde5998d4366 100644
--- a/core/src/components/setup/RecommendedApps.vue
+++ b/core/src/components/setup/RecommendedApps.vue
@@ -42,7 +42,7 @@
@@ -50,7 +50,14 @@
+ {{ t('core', 'Retry') }}
+
+
+ app.isSelected && !app.active)
},
+
+ showSkipButton() {
+ return !this.loadingApps && !this.installingApps && (this.appsLoaded || this.loadingAppsError)
+ },
},
async mounted() {
- try {
- const apps = await appstoreApi.getApps()
- logger.info(`${apps.length} apps fetched`)
-
- this.apps = apps.map((app) => Object.assign(app, {
- loading: false,
- installationError: false,
- isSelected: app.isCompatible && !this.isHidden(app.id),
- }))
- this.$nextTick(() => logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps }))
-
- this.showInstallButton = true
- } catch (error) {
- logger.error('could not fetch app list', { error })
-
- this.loadingAppsError = true
- } finally {
- this.loadingApps = false
- }
+ await this.loadApps()
},
methods: {
+ async loadApps() {
+ this.loadingApps = true
+ this.loadingAppsError = false
+ this.appsLoaded = false
+ this.apps = []
+
+ try {
+ const apps = await appstoreApi.getApps()
+ logger.info(`${apps.length} apps fetched`)
+
+ this.apps = apps.map((app) => ({
+ ...app,
+ loading: false,
+ isSelected: app.isCompatible && !this.isHidden(app.id),
+ }))
+ this.$nextTick(() => logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps }))
+
+ this.appsLoaded = true
+ } catch (error) {
+ logger.error('could not fetch app list', { error })
+
+ this.loadingAppsError = true
+ } finally {
+ this.loadingApps = false
+ }
+ },
+
async installApps() {
const availableApps = this.recommendedApps.filter((app) => app.active || (app.isSelected && canInstall(app)))
const appsToInstall = [
@@ -233,8 +253,8 @@ export default {
},
toggleSelect(appId) {
- // disable toggle when installButton is disabled
- if (!(appId in recommended) || !this.showInstallButton) {
+ // Disable toggles until the app list has loaded successfully.
+ if (!(appId in recommended) || !this.appsLoaded) {
return
}
const index = this.apps.findIndex((app) => app.id === appId)
diff --git a/core/src/tests/components/setup/RecommendedApps.spec.ts b/core/src/tests/components/setup/RecommendedApps.spec.ts
new file mode 100644
index 0000000000000..b11d84ef05b73
--- /dev/null
+++ b/core/src/tests/components/setup/RecommendedApps.spec.ts
@@ -0,0 +1,59 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import RecommendedApps from '../../../components/setup/RecommendedApps.vue'
+
+const getApps = vi.hoisted(() => vi.fn())
+
+vi.mock('~/apps/appstore/src/service/api.ts', () => ({
+ getApps,
+ enableApp: vi.fn(),
+}))
+
+describe('RecommendedApps', () => {
+ afterEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('clears stale apps and hides Install when loading fails', async () => {
+ getApps
+ .mockResolvedValueOnce([
+ {
+ id: 'calendar',
+ name: 'Calendar',
+ isCompatible: true,
+ active: false,
+ },
+ ])
+ .mockRejectedValueOnce(new Error('App Store unavailable'))
+
+ const wrapper = mount(RecommendedApps, {
+ global: {
+ mocks: {
+ t: (_app: string, text: string) => text,
+ },
+ stubs: {
+ NcButton: {
+ template: '',
+ },
+ NcCheckboxRadioSwitch: true,
+ },
+ },
+ })
+
+ await vi.waitFor(() => {
+ expect(wrapper.vm.appsLoaded).toBe(true)
+ })
+
+ expect(wrapper.text()).toContain('Calendar')
+ expect(wrapper.vm.apps).toHaveLength(1)
+
+ await wrapper.vm.loadApps()
+
+ expect(wrapper.vm.apps).toEqual([])
+ expect(wrapper.vm.appsLoaded).toBe(false)
+ expect(wrapper.vm.loadingAppsError).toBe(true)
+ expect(wrapper.text()).not.toContain('Calendar')
+ expect(wrapper.text()).toContain('Could not fetch list of apps from the App Store.')
+ })
+})
diff --git a/tests/playwright/e2e/core/setup.spec.ts b/tests/playwright/e2e/core/setup.spec.ts
index 9f328db6b97eb..c89bad7e51eab 100644
--- a/tests/playwright/e2e/core/setup.spec.ts
+++ b/tests/playwright/e2e/core/setup.spec.ts
@@ -82,8 +82,7 @@ async function completeSetup(page: Page, setupPage: SetupPage, mode: Recommended
await expect(setupPage.installRecommendedButton()).toBeVisible()
if (mode === 'skip') {
- await setupPage.skipButton().click()
- await page.goto('apps/files/')
+ await setupPage.skipRecommendedApps()
await expect(page.locator('[data-cy-files-content]')).toBeVisible()
return
}
@@ -132,6 +131,44 @@ test.describe('Nextcloud installation wizard', { tag: '@setup' }, () => {
await setupPage.selectDatabase('SQLite')
await completeSetup(page, setupPage, 'install-failure')
})
+
+ test('can retry or skip when loading recommended apps fails', async ({ page, setupPage }) => {
+ let appListRequests = 0
+ await page.route(/\/apps\/appstore\/api\/v1\/apps(\?.*)?$/, async (route) => {
+ appListRequests++
+
+ if (appListRequests === 1) {
+ await route.fulfill({ status: 503 })
+ return
+ }
+
+ await route.fulfill({ json: APPSTORE_APPS })
+ })
+ await setupPage.open()
+
+ await setupPage.selectDatabase('SQLite')
+ const admin = randomAdmin()
+ await setupPage.install(admin, admin)
+
+ // A failed initial listing must leave a usable escape hatch, without
+ // exposing the install action for an unavailable app list.
+ await expect(setupPage.recommendedAppsLoadError()).toBeVisible()
+ await expect(setupPage.retryRecommendedAppsButton()).toBeVisible()
+ await expect(setupPage.skipButton()).toBeVisible()
+ await expect(setupPage.installRecommendedButton()).toBeHidden()
+
+ // Retry requests the listing again and restores the normal install UI.
+ await setupPage.retryRecommendedAppsButton().click()
+ await expect(setupPage.recommendedAppsLoadError()).toBeHidden()
+ await expect(setupPage.retryRecommendedAppsButton()).toBeHidden()
+ await expect(setupPage.installRecommendedButton()).toBeVisible()
+ await expect.poll(() => appListRequests).toBe(2)
+
+ // Skip must navigate to the default page rather than leave the user
+ // stranded on the setup screen.
+ await setupPage.skipRecommendedApps()
+ await expect(page.locator('[data-cy-files-content]')).toBeVisible()
+ })
})
test('installs with MySQL', { tag: '@db_mysql' }, async ({ page, setupPage }) => {
diff --git a/tests/playwright/support/sections/SetupPage.ts b/tests/playwright/support/sections/SetupPage.ts
index cf12d22c352c4..44736a85d1a4c 100644
--- a/tests/playwright/support/sections/SetupPage.ts
+++ b/tests/playwright/support/sections/SetupPage.ts
@@ -103,14 +103,41 @@ export class SetupPage {
return this.page.getByRole('heading', { name: 'Recommended apps' })
}
+ recommendedApp(name: string): Locator {
+ return this.recommendedApps().getByRole('heading', { name })
+ }
+
skipButton(): Locator {
return this.page.getByRole('button', { name: 'Skip' })
}
+ recommendedAppsLoadError(): Locator {
+ return this.page.getByText('Could not fetch list of apps from the App Store.')
+ }
+
+ retryRecommendedAppsButton(): Locator {
+ return this.page.getByRole('button', { name: 'Retry' })
+ }
+
installRecommendedButton(): Locator {
return this.page.getByRole('button', { name: 'Install recommended apps' })
}
+ /**
+ * Follow the server-provided default-page URL exposed by the Skip button.
+ */
+ async skipRecommendedApps(): Promise {
+ const href = await this.skipButton().getAttribute('href')
+ if (!href) {
+ throw new Error('Recommended-apps Skip button has no href')
+ }
+
+ await Promise.all([
+ this.page.waitForURL(new URL(href, this.page.url()).toString()),
+ this.skipButton().click(),
+ ])
+ }
+
/**
* Install the recommended apps and confirm the per-app password dialog that
* `@nextcloud/password-confirmation` raises for each enable request. The