diff --git a/.drone.star b/.drone.star index 6a2778bdbc0..33aa19835a1 100644 --- a/.drone.star +++ b/.drone.star @@ -196,6 +196,7 @@ config = { "suites": [ "shares", "search", + "runtime", ], }, "4": { diff --git a/changelog/unreleased/enhancement-add-crash-page.md b/changelog/unreleased/enhancement-add-crash-page.md new file mode 100644 index 00000000000..e9fe58cbcfa --- /dev/null +++ b/changelog/unreleased/enhancement-add-crash-page.md @@ -0,0 +1,5 @@ +Enhancement: Add crash page + +We've added a crash page to the application. This page is displayed when the application encounters an error that it cannot recover from. + +https://github.com/owncloud/web/pull/13426 diff --git a/changelog/unreleased/enhancement-catch-spaces-loading-error.md b/changelog/unreleased/enhancement-catch-spaces-loading-error.md new file mode 100644 index 00000000000..e75f8a2b91c --- /dev/null +++ b/changelog/unreleased/enhancement-catch-spaces-loading-error.md @@ -0,0 +1,5 @@ +Enhancement: Catch spaces loading error + +In case there is any error during spaces loading, the application will now display a crash page instead of showing an infinite loading state. + +https://github.com/owncloud/web/pull/13426 diff --git a/packages/web-pkg/src/errors/codes.ts b/packages/web-pkg/src/errors/codes.ts new file mode 100644 index 00000000000..d1984e78cd0 --- /dev/null +++ b/packages/web-pkg/src/errors/codes.ts @@ -0,0 +1,6 @@ +/** + * Error codes for errors from which the application cannot recover. + */ +export const CRASH_CODES = Object.freeze({ + RUNTIME_BOOTSTRAP_SPACES_LOAD: 'RUNTIME-BOOTSTRAP-040' +}) diff --git a/packages/web-pkg/src/errors/index.ts b/packages/web-pkg/src/errors/index.ts index c9f6f047dc0..dd624b5642c 100644 --- a/packages/web-pkg/src/errors/index.ts +++ b/packages/web-pkg/src/errors/index.ts @@ -1 +1,2 @@ export * from './types' +export * from './codes' diff --git a/packages/web-runtime/src/composables/layout/useLayout.ts b/packages/web-runtime/src/composables/layout/useLayout.ts index 124aab2afc3..6da3f6580eb 100644 --- a/packages/web-runtime/src/composables/layout/useLayout.ts +++ b/packages/web-runtime/src/composables/layout/useLayout.ts @@ -20,7 +20,8 @@ export const useLayout = (options?: LayoutOptions) => { 'oidcCallback', 'oidcSilentRedirect', 'resolvePublicLink', - 'accessDenied' + 'accessDenied', + 'crash' ] if ( !unref(router.currentRoute).name || diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 6b1d9549b90..5ab4393c6c6 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -46,6 +46,8 @@ import Avatar from './components/Avatar.vue' import focusMixin from './mixins/focusMixin' import { extensionPoints } from './extensionPoints' import { isSilentRedirectRoute } from './helpers/silentRedirect' +import { captureException } from '@sentry/vue' +import { CRASH_CODES } from '@ownclouders/web-pkg/src/errors/codes' export const bootstrapApp = async (configurationPath: string, appsReadyCallback: () => void) => { const isSilentRedirect = isSilentRedirectRoute() @@ -232,15 +234,21 @@ export const bootstrapApp = async (configurationPath: string, appsReadyCallback: sharesStore.setGraphRoles(graphRoleDefinitions) // Load spaces to make them available across the application - await spacesStore.loadSpaces({ graphClient: clientService.graphAuthenticated }) - const personalSpace = spacesStore.spaces.find(isPersonalSpaceResource) - - if (personalSpace) { - spacesStore.updateSpaceField({ - id: personalSpace.id, - field: 'name', - value: app.config.globalProperties.$gettext('Personal') - }) + try { + await spacesStore.loadSpaces({ graphClient: clientService.graphAuthenticated }) + const personalSpace = spacesStore.spaces.find(isPersonalSpaceResource) + + if (personalSpace) { + spacesStore.updateSpaceField({ + id: personalSpace.id, + field: 'name', + value: app.config.globalProperties.$gettext('Personal') + }) + } + } catch (error) { + console.error(error) + captureException(error) + router.push({ name: 'crash', query: { code: CRASH_CODES.RUNTIME_BOOTSTRAP_SPACES_LOAD } }) } }, { diff --git a/packages/web-runtime/src/pages/crash.vue b/packages/web-runtime/src/pages/crash.vue new file mode 100644 index 00000000000..8278d445e84 --- /dev/null +++ b/packages/web-runtime/src/pages/crash.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/packages/web-runtime/src/router/index.ts b/packages/web-runtime/src/router/index.ts index 46c66ef63c1..2dcea5afead 100644 --- a/packages/web-runtime/src/router/index.ts +++ b/packages/web-runtime/src/router/index.ts @@ -6,6 +6,7 @@ import NotFoundPage from '../pages/notFound.vue' import OidcCallbackPage from '../pages/oidcCallback.vue' import ResolvePublicLinkPage from '../pages/resolvePublicLink.vue' import ResolvePrivateLinkPage from '../pages/resolvePrivateLink.vue' +import CrashPage from '../pages/crash.vue' import { setupAuthGuard } from './setupAuthGuard' import { patchRouter } from './patchCleanPath' import { @@ -93,6 +94,14 @@ const routes = [ name: 'notFound', component: NotFoundPage, meta: { title: $gettext('Not found'), authContext: 'hybrid' } + }, + { + path: '/crash', + name: 'crash', + component: CrashPage, + meta: { + authContext: 'hybrid' + } } ] export const router = patchRouter( diff --git a/tests/e2e-playwright/specs/runtime/crashPage.spec.ts b/tests/e2e-playwright/specs/runtime/crashPage.spec.ts new file mode 100644 index 00000000000..0993be82a9c --- /dev/null +++ b/tests/e2e-playwright/specs/runtime/crashPage.spec.ts @@ -0,0 +1,57 @@ +import { test } from '@playwright/test' +import { config } from '../../../e2e/config.js' +import { ActorsEnvironment, UsersEnvironment } from '../../../e2e/support/environment' +import { setAccessAndRefreshToken } from '../../helpers/setAccessAndRefreshToken' +import * as api from '../../steps/api/api' +import * as ui from '../../steps/ui/index' +import { CRASH_CODES } from '../../../../packages/web-pkg/src/errors/codes' + +test.describe('crash page', () => { + let actorsEnvironment: ActorsEnvironment + const usersEnvironment = new UsersEnvironment() + + test.beforeEach(async ({ browser }) => { + actorsEnvironment = new ActorsEnvironment({ + context: { + acceptDownloads: config.acceptDownloads, + reportDir: config.reportDir, + tracingReportDir: config.tracingReportDir, + reportHar: config.reportHar, + reportTracing: config.reportTracing, + reportVideo: config.reportVideo, + failOnUncaughtConsoleError: config.failOnUncaughtConsoleError + }, + browser: browser + }) + + await setAccessAndRefreshToken(usersEnvironment) + await api.userHasBeenCreated({ usersEnvironment, stepUser: 'Admin', userToBeCreated: 'Alice' }) + await ui.logInUser({ usersEnvironment, actorsEnvironment, stepUser: 'Alice' }) + }) + + test.afterEach(async () => { + await api.deleteUser({ usersEnvironment, stepUser: 'Admin', targetUser: 'Alice' }) + }) + + test('when spaces loading fails, the crash page is displayed', async () => { + const { page } = actorsEnvironment.getActor({ key: 'Alice' }) + + await page.route('**/me/drives*', (route) => { + route.abort('failed') + }) + await page.reload() + + await ui.expectCrashPageToBeVisible({ page }) + await ui.logOutUser({ actorsEnvironment, stepUser: 'Alice' }) + }) + + test('the crash page does not have any accessibility violations', async () => { + const { page } = actorsEnvironment.getActor({ key: 'Alice' }) + await ui.openCrashPage({ + page, + errorCode: CRASH_CODES.RUNTIME_BOOTSTRAP_SPACES_LOAD + }) + await ui.expectCrashPageToBeVisible({ page }) + await ui.expectCrashPageHasNoAccessibilityViolations({ page }) + }) +}) diff --git a/tests/e2e-playwright/steps/ui/crashPage.ts b/tests/e2e-playwright/steps/ui/crashPage.ts new file mode 100644 index 00000000000..a6c8336038a --- /dev/null +++ b/tests/e2e-playwright/steps/ui/crashPage.ts @@ -0,0 +1,26 @@ +import { CrashPage } from '../../../e2e/support/objects/crash-page' +import { config } from '../../../e2e/config' +import { Page } from '@playwright/test' + +export function expectCrashPageToBeVisible({ page }: { page: Page }) { + const crashPageObject = new CrashPage({ page }) + return crashPageObject.assertVisibility() +} + +export function openCrashPage({ page, errorCode }: { page: Page; errorCode: string }) { + return page.goto(`${config.baseUrl}/crash?errorCode=${errorCode}`) +} + +export async function expectCrashPageHasNoAccessibilityViolations({ page }: { page: Page }) { + if (config.skipA11y) { + return + } + + const crashPageObject = new CrashPage({ page }) + const a11yViolations = await crashPageObject.getAccessibilityViolations() + + expect( + a11yViolations, + `Found ${a11yViolations.length} severe accessibility violations in crash page` + ).toHaveLength(0) +} diff --git a/tests/e2e-playwright/steps/ui/index.ts b/tests/e2e-playwright/steps/ui/index.ts index bd4d3f6755e..b83957ddc03 100644 --- a/tests/e2e-playwright/steps/ui/index.ts +++ b/tests/e2e-playwright/steps/ui/index.ts @@ -6,3 +6,4 @@ export * from './spaces' export * from './application' export * from './account' export * from './appStore' +export * from './crashPage' diff --git a/tests/e2e/support/objects/crash-page/index.ts b/tests/e2e/support/objects/crash-page/index.ts new file mode 100644 index 00000000000..c1d6168b1bb --- /dev/null +++ b/tests/e2e/support/objects/crash-page/index.ts @@ -0,0 +1,23 @@ +import { expect, Page } from '@playwright/test' +import { objects } from '../..' + +const selectors = { + page: '#page-crash' +} + +export class CrashPage { + #page: Page + + constructor({ page }: { page: Page }) { + this.#page = page + } + + assertVisibility() { + return expect(this.#page.locator(selectors.page).first()).toBeVisible() + } + + getAccessibilityViolations() { + const a11yObject = new objects.a11y.Accessibility({ page: this.#page }) + return a11yObject.getSevereAccessibilityViolations(selectors.page) + } +}