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 @@
+
+
+
![]()
+
+
+ {{
+ $pgettext(
+ 'Title of the crash page displayed when the application crashes',
+ 'Application crashed'
+ )
+ }}
+
+
+ {{ errorCode }}
+
+
{{ errorMessage }}
+
+
+
+
+
+
+
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)
+ }
+}