From 9659142096dafa0d13ed6f862bab2a293c836d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 16 Sep 2024 14:58:56 +0200 Subject: [PATCH] refactor(frontend): global error handler Motivation ---------- This will accomplish various things: * Provide `toast` globally, so you can send success messages (e.g. copy to clipboard). * Easier testing: Pass `toast` via dependency injection * Consistency: Only one place to configure `toast` * Code complexity: Remove unnecessarily nested try/catch * Fix a todo: `console.error` in production and development The major refactoring here is that we let errors propagate to the global error handler. Everywhere where we used to call `GlobalErrorHandler` the error was unexpected and we could not do anything about it. In this case, it is best to `throw` properly and halt the code execution. How to test ----------- 1. `docker compose up` 2. `docker compose stop authentik` 3. Visit http://localhost:3000/app/signin 4. See error toast --- frontend/(presenter)/pages/optin/+Page.vue | 4 +- frontend/(presenter)/pages/optin/Page.test.ts | 3 ++ .../buttons/LargeDreamMallButton.vue | 6 +-- .../components/cockpit/about-me/AboutMe.vue | 5 +-- .../cockpit/my-tables/TableItem.vue | 5 +-- .../my-tables/create-table/CreateTable.vue | 4 +- .../malltalk/settings/ChangeUsers.vue | 5 +-- .../malltalk/settings/TableSettingsRoot.vue | 10 ++--- .../components/malltalk/setup/SubmitTable.vue | 13 +++--- .../components/malltalk/setup/TableSetup.vue | 29 +++++------- frontend/app/components/menu/UserDropdown.vue | 5 +-- .../user-selection/useSearchUsers.ts | 5 +-- frontend/app/pages/auth/+Page.vue | 16 +++---- frontend/app/pages/auth/Page.test.ts | 19 ++++---- frontend/app/pages/join-table/+Page.vue | 5 +-- frontend/app/pages/join-table/Page.test.ts | 9 ++-- frontend/app/pages/signin/+Page.vue | 5 +-- frontend/app/pages/signin/Page.test.ts | 7 ++- frontend/app/pages/silent-refresh/+Page.vue | 5 +-- .../app/pages/silent-refresh/Page.test.ts | 7 ++- frontend/app/pages/table/+Page.vue | 3 +- frontend/app/pages/table/Page.test.ts | 12 ++--- frontend/app/utils/copyToClipboard.ts | 21 +++++---- frontend/locales/de.json | 4 +- frontend/locales/en.json | 4 +- frontend/renderer/app.ts | 16 ++----- .../plugins/globalErrorHandler.spec.ts | 44 ++++++++----------- .../renderer/plugins/globalErrorHandler.ts | 42 +++++++++--------- frontend/tests/plugin.globalErrorHandler.ts | 15 ++++--- 29 files changed, 156 insertions(+), 172 deletions(-) diff --git a/frontend/(presenter)/pages/optin/+Page.vue b/frontend/(presenter)/pages/optin/+Page.vue index e7b654f502..011773b451 100644 --- a/frontend/(presenter)/pages/optin/+Page.vue +++ b/frontend/(presenter)/pages/optin/+Page.vue @@ -44,9 +44,7 @@ onBeforeMount(async () => { try { const result = await confirmNewsletterMutation({ code }) isError.value = !result?.data?.confirmNewsletter - } catch (error) { - // eslint-disable-next-line no-console - console.error(error) + } catch { isError.value = true } diff --git a/frontend/(presenter)/pages/optin/Page.test.ts b/frontend/(presenter)/pages/optin/Page.test.ts index e14830d20b..462a5fd3e8 100644 --- a/frontend/(presenter)/pages/optin/Page.test.ts +++ b/frontend/(presenter)/pages/optin/Page.test.ts @@ -8,6 +8,7 @@ import { confirmNewsletter } from '#presenter/graphql/mutations/confirmNewslette import { vikePageContext } from '#renderer/context/usePageContext' import i18n from '#renderer/plugins/i18n' import { mockClient } from '#tests/mock.apolloClient' +import { createMockPlugin } from '#tests/plugin.globalErrorHandler' import OptinPage from './+Page.vue' import route from './+route' @@ -17,6 +18,7 @@ vi.mock('vike/client/router') vi.mocked(navigate).mockResolvedValue() const confirmNewsletterMock = vi.fn() +const { mockPlugin } = createMockPlugin() mockClient.setRequestHandler( confirmNewsletter, @@ -27,6 +29,7 @@ describe('OptinPage', () => { const Wrapper = () => { return mount(VApp, { global: { + plugins: [mockPlugin], provide: { [vikePageContext as symbol]: { routeParams: { diff --git a/frontend/app/components/buttons/LargeDreamMallButton.vue b/frontend/app/components/buttons/LargeDreamMallButton.vue index eb41a3e36d..092b700173 100644 --- a/frontend/app/components/buttons/LargeDreamMallButton.vue +++ b/frontend/app/components/buttons/LargeDreamMallButton.vue @@ -297,8 +297,6 @@ diff --git a/frontend/app/components/malltalk/settings/TableSettingsRoot.vue b/frontend/app/components/malltalk/settings/TableSettingsRoot.vue index a27859da4d..4a842ab3d5 100644 --- a/frontend/app/components/malltalk/settings/TableSettingsRoot.vue +++ b/frontend/app/components/malltalk/settings/TableSettingsRoot.vue @@ -25,7 +25,7 @@ diff --git a/frontend/app/components/malltalk/setup/TableSetup.vue b/frontend/app/components/malltalk/setup/TableSetup.vue index 66703dbaad..58dded33e0 100644 --- a/frontend/app/components/malltalk/setup/TableSetup.vue +++ b/frontend/app/components/malltalk/setup/TableSetup.vue @@ -16,7 +16,6 @@ import { useI18n } from 'vue-i18n' import StepControl from '#app/components/steps/StepControl.vue' import { Step } from '#app/components/steps/useSteps' import { useTablesStore } from '#app/stores/tablesStore' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' import EnterNameAndVisibility from './EnterNameAndVisibility.vue' import SelectUsers from './SelectUsers.vue' @@ -85,24 +84,18 @@ defineEmits<{ }>() const onSubmit = async () => { - try { - const table = await tablesStore.createMyTable( - tableSettings.name, - !tableSettings.isPrivate, - tableSettings.users, - ) - - if (!table) { - GlobalErrorHandler.error('Could not create MyTable') - return - } + const table = await tablesStore.createMyTable( + tableSettings.name, + !tableSettings.isPrivate, + tableSettings.users, + ) + if (!table) { + throw new Error('Could not create MyTable') + } - tableSettings.tableId = await tablesStore.joinMyTable() - if (!tableSettings.tableId) { - GlobalErrorHandler.error('Could not join myTable') - } - } catch (error) { - GlobalErrorHandler.error('Error opening table', error) + tableSettings.tableId = await tablesStore.joinMyTable() + if (!tableSettings.tableId) { + throw new Error('Could not join myTable') } stepControl.value?.next() diff --git a/frontend/app/components/menu/UserDropdown.vue b/frontend/app/components/menu/UserDropdown.vue index 918650b7d7..d83aa938ae 100644 --- a/frontend/app/components/menu/UserDropdown.vue +++ b/frontend/app/components/menu/UserDropdown.vue @@ -12,15 +12,14 @@ import { inject } from 'vue' import AuthService from '#app/services/AuthService' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' const authService = inject('authService') async function signOut() { try { await authService?.signOut() - } catch (error) { - GlobalErrorHandler.error('auth error', error) + } catch (cause) { + throw new Error('auth error', { cause }) } } diff --git a/frontend/app/components/user-selection/useSearchUsers.ts b/frontend/app/components/user-selection/useSearchUsers.ts index 7d76a4dbee..eff1c03319 100644 --- a/frontend/app/components/user-selection/useSearchUsers.ts +++ b/frontend/app/components/user-selection/useSearchUsers.ts @@ -2,7 +2,6 @@ import { useQuery } from '@vue/apollo-composable' import { computed, ref } from 'vue' import { searchUsersQuery } from '#app/graphql/queries/searchUsersQuery' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' export type SearchUser = { id: number @@ -39,8 +38,8 @@ export default function useSearchUsers() { searchString.value = query try { await refetch({ searchString: searchString.value }) - } catch (e) { - GlobalErrorHandler.error('Error searching users:', e) + } catch (cause) { + throw new Error('Error searching users:', { cause }) } } diff --git a/frontend/app/pages/auth/+Page.vue b/frontend/app/pages/auth/+Page.vue index 767065c243..58adc4b134 100644 --- a/frontend/app/pages/auth/+Page.vue +++ b/frontend/app/pages/auth/+Page.vue @@ -7,20 +7,20 @@ import { navigate } from 'vike/client/router' import { inject, onBeforeMount } from 'vue' import AuthService from '#app/services/AuthService' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' const authService = inject('authService') onBeforeMount(async () => { + let user try { - const user = await authService?.signInCallback() - if (!user) { - throw new Error('Could not Sign In') - } - navigate('/app') - } catch (error) { - GlobalErrorHandler.error('auth error', error) + user = await authService?.signInCallback() + } catch (cause) { + throw new Error('auth error', { cause }) } + if (!user) { + throw new Error('Could not sign in') + } + navigate('/app') }) diff --git a/frontend/app/pages/auth/Page.test.ts b/frontend/app/pages/auth/Page.test.ts index d5097a9033..257bf6fb3b 100644 --- a/frontend/app/pages/auth/Page.test.ts +++ b/frontend/app/pages/auth/Page.test.ts @@ -6,7 +6,7 @@ import { VApp } from 'vuetify/components' import i18n from '#renderer/plugins/i18n' import { authService } from '#tests/mock.authService' -import { errorHandlerSpy } from '#tests/plugin.globalErrorHandler' +import { createMockPlugin } from '#tests/plugin.globalErrorHandler' import AuthPage from './+Page.vue' import { title } from './+title' @@ -14,9 +14,12 @@ import { title } from './+title' vi.mock('vike/client/router') vi.mocked(navigate).mockResolvedValue() +const { mockPlugin, errorSpy } = createMockPlugin() + describe('AuthPage', () => { const Wrapper = () => { return mount(VApp, { + global: { plugins: [mockPlugin] }, slots: { default: h(AuthPage as Component), }, @@ -70,16 +73,12 @@ describe('AuthPage', () => { describe('no user returned', () => { beforeEach(() => { vi.clearAllMocks() - authServiceSpy.mockResolvedValue() + authServiceSpy.mockResolvedValue(undefined) + Wrapper() }) it('throws an error', () => { - try { - wrapper = Wrapper() - } catch (err) { - // eslint-disable-next-line vitest/no-conditional-expect - expect(err).toBe('Could not Sign In') - } + expect(errorSpy).toHaveBeenCalledWith(new Error('Could not sign in')) }) }) @@ -87,11 +86,11 @@ describe('AuthPage', () => { beforeEach(() => { vi.clearAllMocks() authServiceSpy.mockRejectedValue('Ouch!') - wrapper = Wrapper() + Wrapper() }) it('logs the error on console', () => { - expect(errorHandlerSpy).toHaveBeenCalledWith('auth error', 'Ouch!') + expect(errorSpy).toHaveBeenCalledWith(new Error('auth error', { cause: 'Ouch!' })) }) }) }) diff --git a/frontend/app/pages/join-table/+Page.vue b/frontend/app/pages/join-table/+Page.vue index 969fe28d72..f50e1a77b6 100644 --- a/frontend/app/pages/join-table/+Page.vue +++ b/frontend/app/pages/join-table/+Page.vue @@ -36,7 +36,6 @@ import { ref } from 'vue' import MainButton from '#app/components/buttons/MainButton.vue' import { joinTableAsGuestQuery } from '#app/graphql/queries/joinTableAsGuestQuery' import { usePageContext } from '#renderer/context/usePageContext' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' const pageContext = usePageContext() const tableId = Number(pageContext.routeParams?.id) @@ -68,8 +67,8 @@ const getTableLink = async () => { if (joinTableAsGuestQueryResult.value) { window.location.href = joinTableAsGuestQueryResult.value.joinTableAsGuest } - } catch (error) { - GlobalErrorHandler.error('table link not found', error) + } catch (cause) { + throw new Error('table link not found', { cause }) } } diff --git a/frontend/app/pages/join-table/Page.test.ts b/frontend/app/pages/join-table/Page.test.ts index 62265188fa..22f3e35193 100644 --- a/frontend/app/pages/join-table/Page.test.ts +++ b/frontend/app/pages/join-table/Page.test.ts @@ -7,13 +7,14 @@ import { Component, h } from 'vue' import { VApp } from 'vuetify/components' import { joinTableAsGuestQuery } from '#app/graphql/queries/joinTableAsGuestQuery' -import { errorHandlerSpy } from '#tests/plugin.globalErrorHandler' +import { createMockPlugin } from '#tests/plugin.globalErrorHandler' import JoinTablePage from './+Page.vue' import Route from './+route' import { title } from './+title' const joinTableAsGuestQueryMock = vi.fn() +const { mockPlugin, errorSpy } = createMockPlugin() const mockClient = createMockClient() @@ -27,6 +28,7 @@ provideApolloClient(mockClient) describe('JoinTablePage', () => { const Wrapper = () => { return mount(VApp, { + global: { plugins: [mockPlugin] }, slots: { default: h(JoinTablePage as Component), }, @@ -86,9 +88,8 @@ describe('JoinTablePage', () => { }) it('logs Table not found', () => { - expect(errorHandlerSpy).toHaveBeenCalledWith( - 'table link not found', - new ApolloError({ errorMessage: 'autsch' }), + expect(errorSpy).toHaveBeenCalledWith( + new Error('table link not found', { cause: new ApolloError({ errorMessage: 'autsch' }) }), ) }) }) diff --git a/frontend/app/pages/signin/+Page.vue b/frontend/app/pages/signin/+Page.vue index 5a79ec8eec..66ca03b06c 100644 --- a/frontend/app/pages/signin/+Page.vue +++ b/frontend/app/pages/signin/+Page.vue @@ -8,7 +8,6 @@ import { inject, onBeforeMount } from 'vue' import AuthService from '#app/services/AuthService' import { useAuthStore } from '#app/stores/authStore' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' const authService = inject('authService') @@ -26,8 +25,8 @@ onBeforeMount(async () => { try { await authService?.signIn() navigate('/app') - } catch (error) { - GlobalErrorHandler.error('auth error', error) + } catch (cause) { + throw new Error('auth error', { cause }) } }) diff --git a/frontend/app/pages/signin/Page.test.ts b/frontend/app/pages/signin/Page.test.ts index 0fdc203659..203cc25af9 100644 --- a/frontend/app/pages/signin/Page.test.ts +++ b/frontend/app/pages/signin/Page.test.ts @@ -7,17 +7,20 @@ import { VApp } from 'vuetify/components' import { useAuthStore } from '#app/stores/authStore' import i18n from '#renderer/plugins/i18n' import { authService } from '#tests/mock.authService' -import { errorHandlerSpy } from '#tests/plugin.globalErrorHandler' +import { createMockPlugin } from '#tests/plugin.globalErrorHandler' import SigninPage from './+Page.vue' import { title } from './+title' +const { mockPlugin, errorSpy } = createMockPlugin() + vi.mock('vike/client/router') vi.mocked(navigate).mockResolvedValue() describe('SigninPage', () => { const Wrapper = () => { return mount(VApp, { + global: { plugins: [mockPlugin] }, slots: { default: h(SigninPage as Component), }, @@ -54,7 +57,7 @@ describe('SigninPage', () => { }) it('logs the error on console', () => { - expect(errorHandlerSpy).toHaveBeenCalledWith('auth error', 'Ouch!') + expect(errorSpy).toHaveBeenCalledWith(new Error('auth error', { cause: 'Ouch!' })) }) }) }) diff --git a/frontend/app/pages/silent-refresh/+Page.vue b/frontend/app/pages/silent-refresh/+Page.vue index f3e804cf53..a1ad7844b5 100644 --- a/frontend/app/pages/silent-refresh/+Page.vue +++ b/frontend/app/pages/silent-refresh/+Page.vue @@ -7,7 +7,6 @@ import { navigate } from 'vike/client/router' import { inject, onBeforeMount } from 'vue' import AuthService from '#app/services/AuthService' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' const authService = inject('authService') @@ -15,8 +14,8 @@ onBeforeMount(async () => { try { await authService?.renewToken() navigate('/') - } catch (error) { - GlobalErrorHandler.error('auth error', error) + } catch (cause) { + throw new Error('auth error', { cause }) } }) diff --git a/frontend/app/pages/silent-refresh/Page.test.ts b/frontend/app/pages/silent-refresh/Page.test.ts index 9cd133182a..026e8c8725 100644 --- a/frontend/app/pages/silent-refresh/Page.test.ts +++ b/frontend/app/pages/silent-refresh/Page.test.ts @@ -6,7 +6,7 @@ import { VApp } from 'vuetify/components' import i18n from '#renderer/plugins/i18n' import { authService } from '#tests/mock.authService' -import { errorHandlerSpy } from '#tests/plugin.globalErrorHandler' +import { createMockPlugin } from '#tests/plugin.globalErrorHandler' import SilentRefreshPage from './+Page.vue' import { title } from './+title' @@ -14,11 +14,14 @@ import { title } from './+title' vi.mock('vike/client/router') vi.mocked(navigate).mockResolvedValue() +const { mockPlugin, errorSpy } = createMockPlugin() + describe('SilentRefreshPage', () => { const authServiceSpy = vi.spyOn(authService, 'renewToken') const Wrapper = () => { return mount(VApp, { + global: { plugins: [mockPlugin] }, slots: { default: h(SilentRefreshPage as Component), }, @@ -56,7 +59,7 @@ describe('SilentRefreshPage', () => { }) it('logs error to console', () => { - expect(errorHandlerSpy).toHaveBeenCalledWith('auth error', 'Ouch!') + expect(errorSpy).toHaveBeenCalledWith(new Error('auth error', { cause: 'Ouch!' })) }) }) }) diff --git a/frontend/app/pages/table/+Page.vue b/frontend/app/pages/table/+Page.vue index bddcf4cb3d..bcb8fafc8f 100644 --- a/frontend/app/pages/table/+Page.vue +++ b/frontend/app/pages/table/+Page.vue @@ -24,7 +24,6 @@ import TableSettings from '#app/components/malltalk/settings/TableSettings.vue' import { joinTableQuery } from '#app/graphql/queries/joinTableQuery' import DefaultLayout from '#app/layouts/DefaultLayout.vue' import { usePageContext } from '#renderer/context/usePageContext' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' const tableUrl = ref(null) @@ -58,9 +57,9 @@ watch(joinTableQueryResult, (data: { joinTable: string }) => { // eslint-disable-next-line promise/prefer-await-to-callbacks watch(joinTableQueryError, (error) => { if (!error) return - GlobalErrorHandler.error('Error opening table', error) errorMessage.value = error.message tableUrl.value = null + throw new Error('Error opening table', { cause: error }) }) const onTableClosed = () => navigate('/') diff --git a/frontend/app/pages/table/Page.test.ts b/frontend/app/pages/table/Page.test.ts index 865cbe5237..c4852f949a 100644 --- a/frontend/app/pages/table/Page.test.ts +++ b/frontend/app/pages/table/Page.test.ts @@ -11,7 +11,7 @@ import { joinTableQuery } from '#app/graphql/queries/joinTableQuery' import { updateOpenTablesSubscription } from '#app/graphql/subscriptions/updateOpenTablesSubscription' import { vikePageContext } from '#renderer/context/usePageContext' import { mockPageContext as globalMockPageContext } from '#tests/mock.vikePageContext' -import { errorHandlerSpy } from '#tests/plugin.globalErrorHandler' +import { createMockPlugin } from '#tests/plugin.globalErrorHandler' import TablePage from './+Page.vue' import Route from './+route' @@ -25,6 +25,8 @@ const updateOpenTablesSubscriptionMock: IMockSubscription = createMockSubscripti const mockClient = createMockClient() +const { mockPlugin, errorSpy } = createMockPlugin() + const META: PageContext['publicEnv']['META'] = { BASE_URL: 'http://localhost:3000', DEFAULT_AUTHOR: 'Whatever', @@ -60,7 +62,9 @@ describe('Table Page', () => { const Wrapper = () => { return mount(VApp, { global: { + plugins: [mockPlugin], provide: { + toast: { success: () => {} }, [vikePageContext as symbol]: mockPageContext, }, }, @@ -108,10 +112,8 @@ describe('Table Page', () => { }) it('toasts an error', () => { - expect(errorHandlerSpy).toHaveBeenCalledWith( - 'Error opening table', - new ApolloError({ errorMessage: 'table does not exist' }), - ) + const cause = new ApolloError({ errorMessage: 'table does not exist' }) + expect(errorSpy).toHaveBeenCalledWith(new Error('Error opening table', { cause })) }) }) diff --git a/frontend/app/utils/copyToClipboard.ts b/frontend/app/utils/copyToClipboard.ts index 25086f2cce..b9a092c218 100644 --- a/frontend/app/utils/copyToClipboard.ts +++ b/frontend/app/utils/copyToClipboard.ts @@ -1,11 +1,16 @@ -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' +import type { toast as Toast } from 'vue3-toastify' -export const copyToClipboard = async (data: string, successMessage: string | null = null) => { - if (typeof window === 'undefined') return - try { - await navigator.clipboard.writeText(data) - if (successMessage) GlobalErrorHandler.success(successMessage) - } catch (err) { - GlobalErrorHandler.error('Failed to url: ', err) +export const copyToClipboard = (toast: typeof Toast | undefined) => { + if (!toast) { + throw new Error('`toast` dependency is undefined') + } + return async (data: string, successMessage: string | null = null) => { + if (typeof window === 'undefined') return + try { + await navigator.clipboard.writeText(data) + if (successMessage) toast.success(successMessage) + } catch (cause) { + throw new Error('Failed to url: ', { cause }) + } } } diff --git a/frontend/locales/de.json b/frontend/locales/de.json index 4fdb3a92d7..0049dd1d17 100644 --- a/frontend/locales/de.json +++ b/frontend/locales/de.json @@ -56,6 +56,7 @@ "share": "Teilen" } }, + "copiedToClipboard": "Link in Zwischenablage kopiert", "dataProtection": { "euDispute": { "content": "Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/. Unsere E-Mail Adresse finden Sie im Impressum.", @@ -116,9 +117,6 @@ }, "title": "DreamMall {'|'} Fehler" }, - "globalErrorHandler": { - "copiedToClipboard": "Link in Zwischenablage kopiert" - }, "home": { "aboutSection": { "buttonTxt": "Persönlicher Kontakt", diff --git a/frontend/locales/en.json b/frontend/locales/en.json index d72b73a0d1..465d58cde0 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -56,6 +56,7 @@ "share": "Share" } }, + "copiedToClipboard": "Link copied to clipboard", "dataProtection": { "euDispute": { "content": "The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr/. You can find our e-mail address in the legal notice.", @@ -116,9 +117,6 @@ }, "title": "DreamMall {'|'} Error" }, - "globalErrorHandler": { - "copiedToClipboard": "Link copied to clipboard" - }, "home": { "aboutSection": { "buttonTxt": "Personal contact", diff --git a/frontend/renderer/app.ts b/frontend/renderer/app.ts index 88da0bbb02..0c425c2cae 100644 --- a/frontend/renderer/app.ts +++ b/frontend/renderer/app.ts @@ -2,22 +2,20 @@ import { DefaultApolloClient } from '@vue/apollo-composable' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import { PageContext } from 'vike/types' import { createSSRApp, defineComponent, h, markRaw, reactive, Component, provide } from 'vue' -import Vue3Toasity from 'vue3-toastify' // eslint-disable-next-line import/no-unassigned-import import 'vue3-toastify/dist/index.css' +import { toast } from 'vue3-toastify' import PageShell from '#app/components/PageShell.vue' import AuthService from '#app/services/AuthService' import { useAuthStore } from '#app/stores/authStore' import { setPageContext } from '#renderer/context/usePageContext' import { createApolloClient } from '#renderer/plugins/apollo' -import GlobalErrorHandler from '#renderer/plugins/globalErrorHandler' +import { globalErrorHandler, toastErrors } from '#renderer/plugins/globalErrorHandler' import i18n from '#renderer/plugins/i18n' import pinia from '#renderer/plugins/pinia' import CreateVuetify from '#renderer/plugins/vuetify' -import type { ToastContainerOptions } from 'vue3-toastify' - const vuetify = CreateVuetify(i18n) function createApp(pageContext: PageContext, isClient = true) { @@ -31,6 +29,7 @@ function createApp(pageContext: PageContext, isClient = true) { ) provide('authService', new AuthService(pageContext.publicEnv.AUTH)) provide('pageContext', pageContext) + provide('toast', toast) }, data: () => ({ Page: markRaw(pageContext.Page), @@ -62,14 +61,7 @@ function createApp(pageContext: PageContext, isClient = true) { app.use(pinia) app.use(i18n) app.use(vuetify) - app.use(Vue3Toasity, { - autoClose: 3000, - style: { - opacity: '1', - userSelect: 'initial', - }, - } as ToastContainerOptions) - app.use(GlobalErrorHandler) + app.use(globalErrorHandler(toastErrors({ toast, console }))) const auth = useAuthStore() diff --git a/frontend/renderer/plugins/globalErrorHandler.spec.ts b/frontend/renderer/plugins/globalErrorHandler.spec.ts index a02dd01ebc..4dfd3e467f 100644 --- a/frontend/renderer/plugins/globalErrorHandler.spec.ts +++ b/frontend/renderer/plugins/globalErrorHandler.spec.ts @@ -1,35 +1,27 @@ +import { mount } from '@vue/test-utils' import { vi, describe, it, expect } from 'vitest' -import { toast } from 'vue3-toastify' +import { defineComponent } from 'vue' -import globalErrorHandler from './globalErrorHandler' - -// vi.mock('vue3-toastify', async (importOriginal) => { -// const mod = await importOriginal() -// return { -// ...mod, -// error: vi.fn(), -// warning: vi.fn(), -// } -// }) +import { globalErrorHandler, toastErrors } from './globalErrorHandler' describe('GlobalErrorHandler', () => { - describe('Error', () => { - const errorSpy = vi.spyOn(toast, 'error') - - it('toasts error message', () => { - globalErrorHandler.error('someError') - - expect(errorSpy).toHaveBeenCalledWith('someError') + describe('given a component throwing an unexpected runtime error', () => { + const toast = { error: vi.fn() } + const console = { error: vi.fn() } + const dependencies = { toast, console } + + const component = defineComponent({ + setup: () => { + throw new Error('Boom!') + }, + template: '', }) - }) - - describe('Warning', () => { - const warningSpy = vi.spyOn(toast, 'warning') - it('toasts warning message', () => { - globalErrorHandler.warning('someWarning') - - expect(warningSpy).toHaveBeenCalledWith('someWarning') + it('toasts error message', () => { + const plugin = globalErrorHandler(toastErrors(dependencies)) + const setup = () => mount(component, { global: { plugins: [plugin] } }) + expect(setup).toThrow('Boom!') + expect(dependencies.toast.error).toHaveBeenCalledWith('Unhandled Error: Boom!') }) }) }) diff --git a/frontend/renderer/plugins/globalErrorHandler.ts b/frontend/renderer/plugins/globalErrorHandler.ts index 3cadab0471..8cc275fae1 100644 --- a/frontend/renderer/plugins/globalErrorHandler.ts +++ b/frontend/renderer/plugins/globalErrorHandler.ts @@ -1,26 +1,28 @@ -import { App } from 'vue' -import { toast } from 'vue3-toastify' +import type { App, AppConfig } from 'vue' +import type { toast as Toast } from 'vue3-toastify' -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const handleError = (message: string, _data?: unknown) => { - toast.error(message) - // TODO log errors while developing, but not in tests - // console.error(message, data) +type Dependencies = { + console: Pick + toast: Pick } -const handleWarning = (message: string) => { - toast.warning(message) -} -const handleSuccess = (message: string) => { - toast.success(message) + +const errorMessage = (error: unknown) => { + if (error instanceof Error && error.cause) { + // The error was wrapped, i. e. we "know" this error. + return error.message + } + return `Unhandled ${String(error)}` } -export default { +export const toastErrors: (deps: Dependencies) => AppConfig['errorHandler'] = + ({ toast, console }) => + (error, _vm, info) => { + toast.error(errorMessage(error)) + console.error(info, error) + } + +export const globalErrorHandler = (errorHandler: AppConfig['errorHandler']) => ({ install: (app: App) => { - app.config.errorHandler = (error, vm, info) => { - handleError(`Unhandled error occurred: ${info}`, error) - } + app.config.errorHandler = errorHandler }, - error: handleError, - warning: handleWarning, - success: handleSuccess, -} +}) diff --git a/frontend/tests/plugin.globalErrorHandler.ts b/frontend/tests/plugin.globalErrorHandler.ts index 402ab50a0c..f836eca6bb 100644 --- a/frontend/tests/plugin.globalErrorHandler.ts +++ b/frontend/tests/plugin.globalErrorHandler.ts @@ -1,9 +1,14 @@ -import { config } from '@vue/test-utils' import { vi } from 'vitest' -import globalErrorHandler from '#renderer/plugins/globalErrorHandler' +import { globalErrorHandler } from '#renderer/plugins/globalErrorHandler' -export const errorHandlerSpy = vi.spyOn(globalErrorHandler, 'error') -export const warningHandlerSpy = vi.spyOn(globalErrorHandler, 'warning') +import type { AppConfig } from 'vue' -config.global.plugins.push(globalErrorHandler) +export const createMockPlugin = () => { + const errorSpy = vi.fn() + const errorHandler: AppConfig['errorHandler'] = (error) => { + errorSpy(error) + } + const mockPlugin = globalErrorHandler(errorHandler) + return { mockPlugin, errorSpy } +}