From aeb756a6ed9dbee72b948e1ee33eee09bf4af3b9 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 3 Aug 2026 17:22:24 -0400 Subject: [PATCH 1/8] fix(setup): Add Retry and Skip actions if appstore req fails Currently if `loadingAppsError` is ever true, the error message gets displayed but there is no button to bypass, try again, etc. There is no navigation present so yet so the user is stuck unless they manually change the URL in the browser. This change gives users a way to continue (or retry) if the App Store is unavailable. Assisted-by: Copilot:gpt-5.6-terra Signed-off-by: Josh --- core/src/components/setup/RecommendedApps.vue | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index 6e9cf8a22d944..eec824310dc45 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -42,13 +42,20 @@
{{ t('core', 'Skip') }} + + {{ t('core', 'Retry') }} + + 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 + + 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 + } + }, + async installApps() { const availableApps = this.recommendedApps.filter((app) => app.active || (app.isSelected && canInstall(app))) const appsToInstall = [ From e2532ea13a0b51a5f17b689d25d00a97115d99ea Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 08:10:31 -0400 Subject: [PATCH 2/8] test(setup): add error/retry locators Signed-off-by: Josh --- .../playwright/support/sections/SetupPage.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/playwright/support/sections/SetupPage.ts b/tests/playwright/support/sections/SetupPage.ts index cf12d22c352c4..4aa3152a9cd37 100644 --- a/tests/playwright/support/sections/SetupPage.ts +++ b/tests/playwright/support/sections/SetupPage.ts @@ -107,10 +107,33 @@ export class SetupPage { 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 From e952bde215d107d7b6ac2651e7e0d1cdfb84a525 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 08:15:54 -0400 Subject: [PATCH 3/8] test(setup): cover scenario where store request fails with retry Signed-off-by: Josh --- tests/playwright/e2e/core/setup.spec.ts | 41 +++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) 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 }) => { From b5e599dcb654a2316367111577c59188813ef6bd Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 08:55:31 -0400 Subject: [PATCH 4/8] chore(setup): make recommended apps retry/skip/install state robust Signed-off-by: Josh --- core/src/components/setup/RecommendedApps.vue | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index eec824310dc45..5bde5998d4366 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -42,7 +42,7 @@
@@ -50,14 +50,14 @@ {{ t('core', 'Retry') }} app.isSelected && !app.active) }, + + showSkipButton() { + return !this.loadingApps && !this.installingApps && (this.appsLoaded || this.loadingAppsError) + }, }, async mounted() { @@ -154,19 +158,21 @@ export default { 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) => Object.assign(app, { + this.apps = apps.map((app) => ({ + ...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 + this.appsLoaded = true } catch (error) { logger.error('could not fetch app list', { error }) @@ -247,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) From 49d30e0a051b9283d2067f13f66291786cf6cec2 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 09:04:09 -0400 Subject: [PATCH 5/8] test(setup): add helper to check for specific rec'd app Signed-off-by: Josh --- tests/playwright/support/sections/SetupPage.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/playwright/support/sections/SetupPage.ts b/tests/playwright/support/sections/SetupPage.ts index 4aa3152a9cd37..44736a85d1a4c 100644 --- a/tests/playwright/support/sections/SetupPage.ts +++ b/tests/playwright/support/sections/SetupPage.ts @@ -103,6 +103,10 @@ 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' }) } From c4145d0fb0c20991f34971a234e094061956aa93 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 09:11:56 -0400 Subject: [PATCH 6/8] test(setup): cover stale apps disappear while retry is pending Signed-off-by: Josh --- tests/playwright/e2e/core/setup.spec.ts | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/playwright/e2e/core/setup.spec.ts b/tests/playwright/e2e/core/setup.spec.ts index c89bad7e51eab..e76a8ba94a54e 100644 --- a/tests/playwright/e2e/core/setup.spec.ts +++ b/tests/playwright/e2e/core/setup.spec.ts @@ -169,6 +169,62 @@ test.describe('Nextcloud installation wizard', { tag: '@setup' }, () => { await setupPage.skipRecommendedApps() await expect(page.locator('[data-cy-files-content]')).toBeVisible() }) + + test('clears loaded apps and Install when retrying the app list fails', async ({ page, setupPage }) => { + let appListRequests = 0 + let retryStarted!: () => void + let releaseRetry!: () => void + const retryRequestStarted = new Promise((resolve) => { + retryStarted = resolve + }) + const continueRetryRequest = new Promise((resolve) => { + releaseRetry = resolve + }) + + await page.route(/\/apps\/appstore\/api\/v1\/apps(\?.*)?$/, async (route) => { + appListRequests++ + + if (appListRequests === 1) { + await route.fulfill({ json: APPSTORE_APPS }) + return + } + + retryStarted() + await continueRetryRequest + await route.fulfill({ status: 503 }) + }) + await setupPage.open() + + await setupPage.selectDatabase('SQLite') + const admin = randomAdmin() + await setupPage.install(admin, admin) + + // Confirm that the initial request loaded the app list normally. + await expect(setupPage.recommendedApp('Calendar')).toBeVisible() + await expect(setupPage.installRecommendedButton()).toBeVisible() + await expect.poll(() => appListRequests).toBe(1) + + // The retry must clear stale state immediately, before the request + // completes. + await setupPage.retryRecommendedAppsButton().click() + await retryRequestStarted + + await expect(page.getByText('Loading apps …')).toBeVisible() + await expect(setupPage.recommendedAppsLoadError()).toBeHidden() + await expect(setupPage.recommendedApp('Calendar')).toBeHidden() + await expect(setupPage.recommendedApp('Contacts')).toBeHidden() + await expect(setupPage.installRecommendedButton()).toBeHidden() + await expect(setupPage.retryRecommendedAppsButton()).toBeHidden() + + // Complete the pending request with an error and verify the final + // recovery state. + releaseRetry() + + await expect(setupPage.recommendedAppsLoadError()).toBeVisible() + await expect(setupPage.installRecommendedButton()).toBeHidden() + await expect(setupPage.retryRecommendedAppsButton()).toBeVisible() + await expect.poll(() => appListRequests).toBe(2) + }) }) test('installs with MySQL', { tag: '@db_mysql' }, async ({ page, setupPage }) => { From eec4cad58a00746e9ec0153e7b2d43ef3c909f1c Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 09:41:10 -0400 Subject: [PATCH 7/8] test(setup): cover RecommendedApps reload failure state Signed-off-by: Josh --- .../components/setup/RecommendedApps.spec.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 core/src/tests/components/setup/RecommendedApps.spec.ts 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.') + }) +}) From fa8411b7ab2296a8c14da033405cdf2c5414b900 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 4 Aug 2026 09:42:49 -0400 Subject: [PATCH 8/8] test(setup): chore - move stale-state coverage out of E2E tests Signed-off-by: Josh --- tests/playwright/e2e/core/setup.spec.ts | 56 ------------------------- 1 file changed, 56 deletions(-) diff --git a/tests/playwright/e2e/core/setup.spec.ts b/tests/playwright/e2e/core/setup.spec.ts index e76a8ba94a54e..c89bad7e51eab 100644 --- a/tests/playwright/e2e/core/setup.spec.ts +++ b/tests/playwright/e2e/core/setup.spec.ts @@ -169,62 +169,6 @@ test.describe('Nextcloud installation wizard', { tag: '@setup' }, () => { await setupPage.skipRecommendedApps() await expect(page.locator('[data-cy-files-content]')).toBeVisible() }) - - test('clears loaded apps and Install when retrying the app list fails', async ({ page, setupPage }) => { - let appListRequests = 0 - let retryStarted!: () => void - let releaseRetry!: () => void - const retryRequestStarted = new Promise((resolve) => { - retryStarted = resolve - }) - const continueRetryRequest = new Promise((resolve) => { - releaseRetry = resolve - }) - - await page.route(/\/apps\/appstore\/api\/v1\/apps(\?.*)?$/, async (route) => { - appListRequests++ - - if (appListRequests === 1) { - await route.fulfill({ json: APPSTORE_APPS }) - return - } - - retryStarted() - await continueRetryRequest - await route.fulfill({ status: 503 }) - }) - await setupPage.open() - - await setupPage.selectDatabase('SQLite') - const admin = randomAdmin() - await setupPage.install(admin, admin) - - // Confirm that the initial request loaded the app list normally. - await expect(setupPage.recommendedApp('Calendar')).toBeVisible() - await expect(setupPage.installRecommendedButton()).toBeVisible() - await expect.poll(() => appListRequests).toBe(1) - - // The retry must clear stale state immediately, before the request - // completes. - await setupPage.retryRecommendedAppsButton().click() - await retryRequestStarted - - await expect(page.getByText('Loading apps …')).toBeVisible() - await expect(setupPage.recommendedAppsLoadError()).toBeHidden() - await expect(setupPage.recommendedApp('Calendar')).toBeHidden() - await expect(setupPage.recommendedApp('Contacts')).toBeHidden() - await expect(setupPage.installRecommendedButton()).toBeHidden() - await expect(setupPage.retryRecommendedAppsButton()).toBeHidden() - - // Complete the pending request with an error and verify the final - // recovery state. - releaseRetry() - - await expect(setupPage.recommendedAppsLoadError()).toBeVisible() - await expect(setupPage.installRecommendedButton()).toBeHidden() - await expect(setupPage.retryRecommendedAppsButton()).toBeVisible() - await expect.poll(() => appListRequests).toBe(2) - }) }) test('installs with MySQL', { tag: '@db_mysql' }, async ({ page, setupPage }) => {