From e91effeda17e68817aa7bafaaf24a7ffa8185776 Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Wed, 9 Sep 2020 21:49:19 -0400 Subject: [PATCH] createLocalVue errorHandler Option (#1670) * refactor(createlocalvue): move createLocalVue to shared utils * refactor(createlocalvue): rename createLocalVue to _createLocalVue Rename createLocalVue to _createLocalVue to indicate private use * improvement(components): add Sync and Async components for testing * improvement(flow): add VueConfig to Flow * improvement(index): export new createLocalVue as default in Index * improvement(_createlocalvue): allow registration of user defined config Allow VueConfig to be passed in via createLocalVue to be registered on the created vue instance * improvement(find): add findAllParentInstances to the find API add findAllParentInstances method to traverse a component's parent to find globally registered properties via createLocalVue * improvement(mount): pass localVue into mounted createLocalVue Pass localVue into mounted createLocalVue to register localVue properties on component * improvement(error): call user defined errorHandler if defined Call the user defined errorHandler created on the localVue instance via createLocalVue. This is called in the VTU global error handler * improvement(createlocalvue): add tests to createLocalVue errorHandler add tests to createLocalVue errorHandler to test invocation on sync and async throws * docs(createlocalvue): document public createLocalVue API * docs(createlocalvue): document the createLocalVue internal API * docs(createlocalvue): document the errorHandler option in createLocalVue * fix(createlocalvue tests): wrap createLocalVue async test in try/finally Wrap async error test for createLocalVue errorHandler in try/finally to prevent global errors * fix(createlocalvue): skip async component throws for vue < 2.6 * improvement(find and error): add additional type safety to find & error Add additional type safety to find and error for older versions of vue that might propagate a vm value of null/undefined * fix(createlocalvue): only run sync error tests for vue versions < 2.4 --- docs/api/createLocalVue.md | 29 ++++++++ docs/ja/api/createLocalVue.md | 29 ++++++++ docs/ru/api/createLocalVue.md | 29 ++++++++ docs/zh/api/createLocalVue.md | 29 ++++++++ flow/options.flow.js | 4 ++ .../server-test-utils/src/renderToString.js | 4 +- packages/shared/create-local-vue.js | 67 +++++++++++++++++++ packages/test-utils/src/create-local-vue.js | 60 +++-------------- packages/test-utils/src/error.js | 23 ++++++- packages/test-utils/src/find.js | 20 ++++++ packages/test-utils/src/mount.js | 7 +- .../components/component-with-async-error.vue | 12 ++++ .../components/component-with-sync-error.vue | 11 +++ test/specs/create-local-vue.spec.js | 44 +++++++++++- 14 files changed, 309 insertions(+), 59 deletions(-) create mode 100644 packages/shared/create-local-vue.js create mode 100644 test/resources/components/component-with-async-error.vue create mode 100644 test/resources/components/component-with-sync-error.vue diff --git a/docs/api/createLocalVue.md b/docs/api/createLocalVue.md index d7066a99f..84025e653 100644 --- a/docs/api/createLocalVue.md +++ b/docs/api/createLocalVue.md @@ -1,5 +1,10 @@ ## createLocalVue() +- **Arguments:** + + - `{Object} options` + - `{Function} errorHandler` + - **Returns:** - `{Component}` @@ -8,8 +13,12 @@ `createLocalVue` returns a Vue class for you to add components, mixins and install plugins without polluting the global Vue class. +The `errorHandler` option can be used to handle uncaught errors during component render function and watchers. + Use it with `options.localVue`: +**Without options:** + ```js import { createLocalVue, shallowMount } from '@vue/test-utils' import MyPlugin from 'my-plugin' @@ -27,4 +36,24 @@ const freshWrapper = shallowMount(Foo) expect(freshWrapper.vm.foo).toBe(false) ``` +**With the [`errorHandler`](https://vuejs.org/v2/api/#errorHandler) option:** + +```js +import { createLocalVue, shallowMount } from '@vue/test-utils' +import Foo from './Foo.vue' + +const errorHandler = (err, vm, info) => { + expect(err).toBeInstanceOf(Error) +} + +const localVue = createLocalVue({ + errorHandler +}) + +// Foo throws an error inside a lifecycle hook +const wrapper = shallowMount(Foo, { + localVue +}) +``` + - **See also:** [Common Tips](../guides/common-tips.md#applying-global-plugins-and-mixins) diff --git a/docs/ja/api/createLocalVue.md b/docs/ja/api/createLocalVue.md index 294e67934..303dee96d 100644 --- a/docs/ja/api/createLocalVue.md +++ b/docs/ja/api/createLocalVue.md @@ -1,5 +1,10 @@ ## createLocalVue() +- **引数:** + + - `{Object} options` + - `{Function} errorHandler` + - **戻り値:** - `{Component}` @@ -8,8 +13,12 @@ `createLocalVue` は、グローバル Vue クラスを汚染することなくコンポーネント、ミックスイン、プラグインを追加するための Vue クラスを返します。 +[errorHandler](https://jp.vuejs.org/v2/api/index.html#errorHandler)オプションを使用すると、コンポーネントのレンダー機能とウォッチャー中にキャッチされなかったエラーを処理できます。 + `options.localVue` と一緒に使用してください。 +**オプションなし:** + ```js import { createLocalVue, shallowMount } from '@vue/test-utils' import Foo from './Foo.vue' @@ -25,4 +34,24 @@ const freshWrapper = shallowMount(Foo) expect(freshWrapper.vm.foo).toBe(false) ``` +**[errorHandler](https://jp.vuejs.org/v2/api/index.html#errorHandler)オプションを使用:** + +```js +import { createLocalVue, shallowMount } from '@vue/test-utils' +import Foo from './Foo.vue' + +const errorHandler = (err, vm, info) => { + expect(err).toBeInstanceOf(Error) +} + +const localVue = createLocalVue({ + errorHandler +}) + +// Fooがライフサイクルフック内でエラーをスローする +const wrapper = shallowMount(Foo, { + localVue +}) +``` + - **参照:** [一般的なヒント](../guides/common-tips.md#グローバルプラグインとミックスインの適用) diff --git a/docs/ru/api/createLocalVue.md b/docs/ru/api/createLocalVue.md index 6fd97811a..c39746c16 100644 --- a/docs/ru/api/createLocalVue.md +++ b/docs/ru/api/createLocalVue.md @@ -1,5 +1,10 @@ ## createLocalVue() +- **Аргументы:** + + - `{Object} options` + - `{Function} errorHandler` + - **Возвращает:** - `{Component}` @@ -8,8 +13,12 @@ `createLocalVue` возвращает класс Vue, чтобы вы могли добавлять компоненты, примеси и устанавливать плагины без загрязнения глобального класса Vue. +Опция [errorHandler](https://ru.vuejs.org/v2/api/index.html#errorHandler) может использоваться для обработки неперехваченных ошибок во время функции визуализации компонента и наблюдателей. + Используйте вместе с `options.localVue`: +**Без опций:** + ```js import { createLocalVue, shallowMount } from '@vue/test-utils' import Foo from './Foo.vue' @@ -25,4 +34,24 @@ const freshWrapper = shallowMount(Foo) expect(freshWrapper.vm.foo).toBe(false) ``` +**С опцией [errorHandler](https://ru.vuejs.org/v2/api/index.html#errorHandler):** + +```js +import { createLocalVue, shallowMount } from '@vue/test-utils' +import Foo from './Foo.vue' + +const errorHandler = (err, vm, info) => { + expect(err).toBeInstanceOf(Error) +} + +const localVue = createLocalVue({ + errorHandler +}) + +// Foo выдает ошибку внутри ловушки жизненного цикла +const wrapper = shallowMount(Foo, { + localVue +}) +``` + - **См. также:** [Общие советы](../guides/common-tips.md#добавnение-гnобаnьных-пnагинов-и-примесей) diff --git a/docs/zh/api/createLocalVue.md b/docs/zh/api/createLocalVue.md index cf0128dbe..b11ca3728 100644 --- a/docs/zh/api/createLocalVue.md +++ b/docs/zh/api/createLocalVue.md @@ -1,5 +1,10 @@ ## createLocalVue() +- **参数:** + + - `{Object} options` + - `{Function} errorHandler` + - **返回值:** - `{Component}` @@ -8,8 +13,12 @@ `createLocalVue` 返回一个 Vue 的类供你添加组件、混入和安装插件而不会污染全局的 Vue 类。 +在组件渲染功能和观察者期间,[`errorHandler`](https://cn.vuejs.org/v2/api/index.html#errorHandler)选项可用于处理未捕获的错误。 + 可通过 `options.localVue` 来使用: +**Without options:** + ```js import { createLocalVue, shallowMount } from '@vue/test-utils' import MyPlugin from 'my-plugin' @@ -27,4 +36,24 @@ const freshWrapper = shallowMount(Foo) expect(freshWrapper.vm.foo).toBe(false) ``` +**使用[`errorHandler`](https://cn.vuejs.org/v2/api/index.html#errorHandler)选项:** + +```js +import { createLocalVue, shallowMount } from '@vue/test-utils' +import Foo from './Foo.vue' + +const errorHandler = (err, vm, info) => { + expect(err).toBeInstanceOf(Error) +} + +const localVue = createLocalVue({ + errorHandler +}) + +// Foo在生命周期挂钩中引发错误 +const wrapper = shallowMount(Foo, { + localVue +}) +``` + - **延伸阅读:**[常用技巧](../guides/common-tips.md#applying-global-plugins-and-mixins) diff --git a/flow/options.flow.js b/flow/options.flow.js index 992cf3d8b..476f73695 100644 --- a/flow/options.flow.js +++ b/flow/options.flow.js @@ -36,6 +36,10 @@ declare type NormalizedOptions = { shouldProxy?: boolean } +declare type VueConfig = { + errorHandler?: Function +} + declare type SlotValue = Component | string | Array declare type SlotsObject = { [name: string]: SlotValue } diff --git a/packages/server-test-utils/src/renderToString.js b/packages/server-test-utils/src/renderToString.js index c6d0b11b5..3e0e10143 100644 --- a/packages/server-test-utils/src/renderToString.js +++ b/packages/server-test-utils/src/renderToString.js @@ -6,7 +6,7 @@ import { throwError } from 'shared/util' import { createRenderer } from 'vue-server-renderer' import { mergeOptions } from 'shared/merge-options' import config from './config' -import testUtils from '@vue/test-utils' +import _createLocalVue from 'shared/create-local-vue' import { validateOptions } from 'shared/validate-options' Vue.config.productionTip = false @@ -34,7 +34,7 @@ export default function renderToString( const vm = createInstance( component, mergedOptions, - testUtils.createLocalVue(options.localVue) + _createLocalVue(options.localVue) ) return renderer.renderToString(vm) diff --git a/packages/shared/create-local-vue.js b/packages/shared/create-local-vue.js new file mode 100644 index 000000000..34f3b0d22 --- /dev/null +++ b/packages/shared/create-local-vue.js @@ -0,0 +1,67 @@ +// @flow + +import Vue from 'vue' +import cloneDeep from 'lodash/cloneDeep' + +/** + * Used internally by vue-server-test-utils and test-utils to propagate/create vue instances. + * This method is wrapped by createLocalVue in test-utils to provide a different public API signature + * @param {Component} _Vue + * @param {VueConfig} config + * @returns {Component} + */ +function _createLocalVue( + _Vue: Component = Vue, + config: VueConfig = {} +): Component { + const instance = _Vue.extend() + + // clone global APIs + Object.keys(_Vue).forEach(key => { + if (!instance.hasOwnProperty(key)) { + const original = _Vue[key] + // cloneDeep can fail when cloning Vue instances + // cloneDeep checks that the instance has a Symbol + // which errors in Vue < 2.17 (https://github.com/vuejs/vue/pull/7878) + try { + instance[key] = + typeof original === 'object' ? cloneDeep(original) : original + } catch (e) { + instance[key] = original + } + } + }) + + // config is not enumerable + instance.config = cloneDeep(Vue.config) + + // if a user defined errorHandler is defined by a localVue instance via createLocalVue, register it + instance.config.errorHandler = config.errorHandler || Vue.config.errorHandler + + // option merge strategies need to be exposed by reference + // so that merge strats registered by plugins can work properly + instance.config.optionMergeStrategies = Vue.config.optionMergeStrategies + + // make sure all extends are based on this instance. + // this is important so that global components registered by plugins, + // e.g. router-link are created using the correct base constructor + instance.options._base = instance + + // compat for vue-router < 2.7.1 where it does not allow multiple installs + if (instance._installedPlugins && instance._installedPlugins.length) { + instance._installedPlugins.length = 0 + } + const use = instance.use + instance.use = (plugin, ...rest) => { + if (plugin.installed === true) { + plugin.installed = false + } + if (plugin.install && plugin.install.installed === true) { + plugin.install.installed = false + } + use.call(instance, plugin, ...rest) + } + return instance +} + +export default _createLocalVue diff --git a/packages/test-utils/src/create-local-vue.js b/packages/test-utils/src/create-local-vue.js index 6c81f93b4..328b7d15d 100644 --- a/packages/test-utils/src/create-local-vue.js +++ b/packages/test-utils/src/create-local-vue.js @@ -1,56 +1,14 @@ // @flow -import Vue from 'vue' -import cloneDeep from 'lodash/cloneDeep' - -function createLocalVue(_Vue: Component = Vue): Component { - const instance = _Vue.extend() - - // clone global APIs - Object.keys(_Vue).forEach(key => { - if (!instance.hasOwnProperty(key)) { - const original = _Vue[key] - // cloneDeep can fail when cloning Vue instances - // cloneDeep checks that the instance has a Symbol - // which errors in Vue < 2.17 (https://github.com/vuejs/vue/pull/7878) - try { - instance[key] = - typeof original === 'object' ? cloneDeep(original) : original - } catch (e) { - instance[key] = original - } - } - }) - - // config is not enumerable - instance.config = cloneDeep(Vue.config) - - instance.config.errorHandler = Vue.config.errorHandler - - // option merge strategies need to be exposed by reference - // so that merge strats registered by plugins can work properly - instance.config.optionMergeStrategies = Vue.config.optionMergeStrategies - - // make sure all extends are based on this instance. - // this is important so that global components registered by plugins, - // e.g. router-link are created using the correct base constructor - instance.options._base = instance - - // compat for vue-router < 2.7.1 where it does not allow multiple installs - if (instance._installedPlugins && instance._installedPlugins.length) { - instance._installedPlugins.length = 0 - } - const use = instance.use - instance.use = (plugin, ...rest) => { - if (plugin.installed === true) { - plugin.installed = false - } - if (plugin.install && plugin.install.installed === true) { - plugin.install.installed = false - } - use.call(instance, plugin, ...rest) - } - return instance +import _createLocalVue from 'shared/create-local-vue' + +/** + * Returns a local vue instance to add components, mixins and install plugins without polluting the global Vue class + * @param {VueConfig} config + * @returns {Component} + */ +function createLocalVue(config: VueConfig = {}): Component { + return _createLocalVue(undefined, config) } export default createLocalVue diff --git a/packages/test-utils/src/error.js b/packages/test-utils/src/error.js index c20ad4003..dc2ba1a53 100644 --- a/packages/test-utils/src/error.js +++ b/packages/test-utils/src/error.js @@ -1,14 +1,33 @@ import { warn } from 'shared/util' -import { findAllInstances } from './find' +import { findAllInstances, findAllParentInstances } from './find' -function errorHandler(errorOrString, vm) { +function errorHandler(errorOrString, vm, info) { const error = typeof errorOrString === 'object' ? errorOrString : new Error(errorOrString) + // If a user defined errorHandler was register via createLocalVue + // find and call the user defined errorHandler + const instancedErrorHandlers = findAllParentInstances(vm) + .filter( + _vm => + _vm && + _vm.$options && + _vm.$options.localVue && + _vm.$options.localVue.config && + _vm.$options.localVue.config.errorHandler + ) + .map(_vm => _vm.$options.localVue.config.errorHandler) + if (vm) { vm._error = error } + // should be one error handler, as only once can be registered with local vue + // regardless, if more exist (for whatever reason), invoke the other user defined error handlers + instancedErrorHandlers.forEach(instancedErrorHandler => { + instancedErrorHandler(error, vm, info) + }) + throw error } diff --git a/packages/test-utils/src/find.js b/packages/test-utils/src/find.js index d769f6c76..2cf2be47c 100644 --- a/packages/test-utils/src/find.js +++ b/packages/test-utils/src/find.js @@ -10,6 +10,26 @@ import { import { throwError } from 'shared/util' import { matches } from './matches' +/** + * Traverses a vue instance for its parents and returns them in an array format + * @param {Component} vm + * @returns {Component[]} The component and its corresponding parents, in order from left to right + */ +export function findAllParentInstances(childVm: any) { + const instances = [childVm] + + function getParent(_vm) { + if (_vm && _vm.$parent) { + instances.push(_vm.$parent) + return getParent(_vm.$parent) + } + return _vm + } + + getParent(childVm) + return instances +} + export function findAllInstances(rootVm: any) { const instances = [rootVm] let i = 0 diff --git a/packages/test-utils/src/mount.js b/packages/test-utils/src/mount.js index 0a0ef4128..4d3b8a6ce 100644 --- a/packages/test-utils/src/mount.js +++ b/packages/test-utils/src/mount.js @@ -7,7 +7,7 @@ import config from './config' import warnIfNoWindow from './warn-if-no-window' import polyfill from './polyfill' import createWrapper from './create-wrapper' -import createLocalVue from './create-local-vue' +import _createLocalVue from 'shared/create-local-vue' import { validateOptions } from 'shared/validate-options' Vue.config.productionTip = false @@ -20,7 +20,10 @@ export default function mount(component, options = {}) { addGlobalErrorHandler(Vue) - const _Vue = createLocalVue(options.localVue) + const _Vue = _createLocalVue( + options.localVue, + options.localVue ? options.localVue.config : undefined + ) const mergedOptions = mergeOptions(options, config) diff --git a/test/resources/components/component-with-async-error.vue b/test/resources/components/component-with-async-error.vue new file mode 100644 index 000000000..3cc17dd93 --- /dev/null +++ b/test/resources/components/component-with-async-error.vue @@ -0,0 +1,12 @@ + + + diff --git a/test/resources/components/component-with-sync-error.vue b/test/resources/components/component-with-sync-error.vue new file mode 100644 index 000000000..ae5152b4e --- /dev/null +++ b/test/resources/components/component-with-sync-error.vue @@ -0,0 +1,11 @@ + + + diff --git a/test/specs/create-local-vue.spec.js b/test/specs/create-local-vue.spec.js index 09a928a01..f8815a6de 100644 --- a/test/specs/create-local-vue.spec.js +++ b/test/specs/create-local-vue.spec.js @@ -5,8 +5,10 @@ import { createLocalVue } from 'packages/test-utils/src' import Component from '~resources/components/component.vue' import ComponentWithVuex from '~resources/components/component-with-vuex.vue' import ComponentWithRouter from '~resources/components/component-with-router.vue' -import { describeWithShallowAndMount } from '~resources/utils' -import { itDoNotRunIf } from 'conditional-specs' +import ComponentWithSyncError from '~resources/components/component-with-sync-error.vue' +import ComponentWithAsyncError from '~resources/components/component-with-async-error.vue' +import { describeWithShallowAndMount, vueVersion } from '~resources/utils' +import { itDoNotRunIf, itSkipIf } from 'conditional-specs' describeWithShallowAndMount('createLocalVue', mountingMethod => { it('installs Vuex without polluting global Vue', () => { @@ -132,4 +134,42 @@ describeWithShallowAndMount('createLocalVue', mountingMethod => { } expect(installCount).toEqual(2) }) + + itSkipIf( + vueVersion < 2.4, + 'Calls `errorHandler` when an error is thrown synchronously', + () => { + const errorHandler = jest.fn() + const localVue = createLocalVue({ + errorHandler + }) + try { + mountingMethod(ComponentWithSyncError, { localVue }) + } catch (e) { + // asserting arguments is a bit difficult due to multiple Vue version support. Please see https://vuejs.org/v2/api/#errorHandler for more details + expect(errorHandler).toHaveBeenCalledTimes(1) + } + } + ) + + itSkipIf( + process.env.TEST_ENV === 'browser' || vueVersion < 2.6, + 'Calls `errorHandler` when an error is thrown asynchronously', + async () => { + const errorHandler = jest.fn() + const localVue = createLocalVue({ + errorHandler + }) + + try { + mountingMethod(ComponentWithAsyncError, { localVue }) + + await Vue.nextTick() + await setTimeout() + } finally { + // asserting arguments is a bit difficult due to multiple Vue version support. Please see https://vuejs.org/v2/api/#errorHandler for more details + expect(errorHandler).toHaveBeenCalledTimes(1) + } + } + ) })