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