From f3d0d3f1717fd3cc3e406d3ac3f4ace316967211 Mon Sep 17 00:00:00 2001 From: Joseph Nields Date: Fri, 10 Apr 2020 05:50:24 -0700 Subject: [PATCH] feat: add support of arbitrary mounting point via attachTo option (#1492) This allows for users to specify where in the document their component should attach, either through a CSS selector string or a provided HTMLElement. This option is passed through directly to the vm.$mount method that is called as part of mount.js. This enables testing of SSR code with Vue test utils as well as rendering of applications via vue-test-utils in contexts that aren't entirely Vue fixes #1492 --- docs/api/mount.md | 5 +- docs/api/options.md | 32 +++++++- docs/api/shallowMount.md | 6 +- docs/api/wrapper/destroy.md | 2 +- flow/options.flow.js | 2 + packages/shared/validate-options.js | 20 ++++- packages/shared/validators.js | 8 ++ packages/test-utils/src/mount.js | 5 +- test/specs/mount.spec.js | 2 + test/specs/mounting-options/attachTo.spec.js | 77 ++++++++++++++++++++ 10 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 test/specs/mounting-options/attachTo.spec.js diff --git a/docs/api/mount.md b/docs/api/mount.md index 240c15b97..e52129576 100644 --- a/docs/api/mount.md +++ b/docs/api/mount.md @@ -55,10 +55,13 @@ import Foo from './Foo.vue' describe('Foo', () => { it('renders a div', () => { + const div = document.createElement('div') + document.body.appendChild(div) const wrapper = mount(Foo, { - attachToDocument: true + attachTo: div }) expect(wrapper.contains('div')).toBe(true) + wrapper.destroy() }) }) ``` diff --git a/docs/api/options.md b/docs/api/options.md index f3b13fce5..2b8f85904 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -16,6 +16,7 @@ These options will be merged with the component's existing options when mounted - [`stubs`](#stubs) - [`mocks`](#mocks) - [`localVue`](#localvue) +- [`attachTo`](#attachto) - [`attachToDocument`](#attachtodocument) - [`propsData`](#propsdata) - [`attrs`](#attrs) @@ -288,12 +289,41 @@ const wrapper = mount(Component, { expect(wrapper.vm.$route).toBeInstanceOf(Object) ``` +## attachTo + +- type: `HTMLElement | string` +- default: `null` + +This either specifies a specific HTMLElement or CSS selector string targeting an +HTMLElement, to which your component will be fully mounted in the document. + +When attaching to the DOM, you should call `wrapper.destroy()` at the end of your test to +remove the rendered elements from the document and destroy the component instance. + +```js +const Component = { + template: '
ABC
', + props: ['msg'] +} +let wrapper = mount(Component, { + attachTo: '#root' +}) +expect(wrapper.vm.$el.parentNode).to.not.be.null +wrapper.destroy() + +wrapper = mount(Component, { + attachTo: document.getElementById('root') +}) +expect(wrapper.vm.$el.parentNode).to.not.be.null +wrapper.destroy() +``` + ## attachToDocument - type: `boolean` - default: `false` -Component will be attached to DOM when rendered if set to `true`. +Like [`attachTo`](#attachto), but automatically creates a new `div` element for you and inserts it into the body. This is deprecated in favor of [`attachTo`](#attachto). When attaching to the DOM, you should call `wrapper.destroy()` at the end of your test to remove the rendered elements from the document and destroy the component instance. diff --git a/docs/api/shallowMount.md b/docs/api/shallowMount.md index a9d74fa70..74c3be5e2 100644 --- a/docs/api/shallowMount.md +++ b/docs/api/shallowMount.md @@ -4,6 +4,7 @@ - `{Component} component` - `{Object} options` + - `{HTMLElement|string} string` - `{boolean} attachToDocument` - `{Object} context` - `{Array|Component} children` @@ -64,10 +65,13 @@ import Foo from './Foo.vue' describe('Foo', () => { it('renders a div', () => { + const div = document.createElement('div') + document.body.appendChild(div) const wrapper = shallowMount(Foo, { - attachToDocument: true + attachTo: div }) expect(wrapper.contains('div')).toBe(true) + wrapper.destroy() }) }) ``` diff --git a/docs/api/wrapper/destroy.md b/docs/api/wrapper/destroy.md index 5857c1867..1808d7a2d 100644 --- a/docs/api/wrapper/destroy.md +++ b/docs/api/wrapper/destroy.md @@ -18,7 +18,7 @@ mount({ expect(spy.calledOnce).toBe(true) ``` -if `attachToDocument` was set to `true` when mounted, the component DOM elements will +if either the `attachTo` or `attachToDocument` option caused the component to mount to the document, the component DOM elements will also be removed from the document. For functional components, `destroy` only removes the rendered DOM elements from the document. diff --git a/flow/options.flow.js b/flow/options.flow.js index 92f1a7231..992cf3d8b 100644 --- a/flow/options.flow.js +++ b/flow/options.flow.js @@ -1,6 +1,7 @@ declare type Options = { // eslint-disable-line no-undef attachToDocument?: boolean, + attachTo?: HTMLElement | string, propsData?: Object, mocks?: Object, methods?: { [key: string]: Function }, @@ -17,6 +18,7 @@ declare type Options = { } declare type NormalizedOptions = { + attachTo?: HTMLElement | string, attachToDocument?: boolean, propsData?: Object, mocks: Object, diff --git a/packages/shared/validate-options.js b/packages/shared/validate-options.js index 1ad9c90ac..452d64eb4 100644 --- a/packages/shared/validate-options.js +++ b/packages/shared/validate-options.js @@ -1,11 +1,13 @@ import { isPlainObject, isFunctionalComponent, - isConstructor + isConstructor, + isDomSelector, + isHTMLElement } from './validators' import { VUE_VERSION } from './consts' import { compileTemplateForSlots } from './compile-template' -import { throwError } from './util' +import { throwError, warn } from './util' import { validateSlots } from './validate-slots' function vueExtendUnsupportedOption(option) { @@ -22,6 +24,20 @@ function vueExtendUnsupportedOption(option) { const UNSUPPORTED_VERSION_OPTIONS = ['mocks', 'stubs', 'localVue'] export function validateOptions(options, component) { + if ( + options.attachTo && + !isHTMLElement(options.attachTo) && + !isDomSelector(options.attachTo) + ) { + throwError( + `options.attachTo should be a valid HTMLElement or CSS selector string` + ) + } + if ('attachToDocument' in options) { + warn( + `options.attachToDocument is deprecated in favor of options.attachTo and will be removed in a future release` + ) + } if (options.parentComponent && !isPlainObject(options.parentComponent)) { throwError( `options.parentComponent should be a valid Vue component options object` diff --git a/packages/shared/validators.js b/packages/shared/validators.js index 09fcb384b..55da960d6 100644 --- a/packages/shared/validators.js +++ b/packages/shared/validators.js @@ -112,6 +112,14 @@ export function isPlainObject(c: any): boolean { return Object.prototype.toString.call(c) === '[object Object]' } +export function isHTMLElement(c: any): boolean { + if (typeof HTMLElement === 'undefined') { + return false + } + // eslint-disable-next-line no-undef + return c instanceof HTMLElement +} + export function isRequiredComponent(name: string): boolean { return ( name === 'KeepAlive' || name === 'Transition' || name === 'TransitionGroup' diff --git a/packages/test-utils/src/mount.js b/packages/test-utils/src/mount.js index c6dab106d..0a0ef4128 100644 --- a/packages/test-utils/src/mount.js +++ b/packages/test-utils/src/mount.js @@ -28,7 +28,8 @@ export default function mount(component, options = {}) { const parentVm = createInstance(component, mergedOptions, _Vue) - const el = options.attachToDocument ? createElement() : undefined + const el = + options.attachTo || (options.attachToDocument ? createElement() : undefined) const vm = parentVm.$mount(el) component._Ctor = {} @@ -36,7 +37,7 @@ export default function mount(component, options = {}) { throwIfInstancesThrew(vm) const wrapperOptions = { - attachedToDocument: !!mergedOptions.attachToDocument + attachedToDocument: !!el } const root = parentVm.$options._isFunctionalContainer diff --git a/test/specs/mount.spec.js b/test/specs/mount.spec.js index 279f9224a..d9869c512 100644 --- a/test/specs/mount.spec.js +++ b/test/specs/mount.spec.js @@ -293,6 +293,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => { expect(wrapper.vm.$options.context).to.equal(undefined) expect(wrapper.vm.$options.attrs).to.equal(undefined) expect(wrapper.vm.$options.listeners).to.equal(undefined) + wrapper.destroy() }) itDoNotRunIf(vueVersion < 2.3, 'injects store correctly', () => { @@ -366,6 +367,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => { '

\n' + '' ) + wrapper.destroy() }) it('overwrites the component options with the instance options', () => { diff --git a/test/specs/mounting-options/attachTo.spec.js b/test/specs/mounting-options/attachTo.spec.js new file mode 100644 index 000000000..ff519812a --- /dev/null +++ b/test/specs/mounting-options/attachTo.spec.js @@ -0,0 +1,77 @@ +import { describeWithShallowAndMount } from '~resources/utils' + +const innerHTML = 'Hello world' +const outerHTML = `
${innerHTML}
` +const ssrHTML = `
${innerHTML}
` +const template = '
Hello world
' +const TestComponent = { template } + +describeWithShallowAndMount('options.attachTo', mountingMethod => { + it('should not mount to document when null', () => { + const wrapper = mountingMethod(TestComponent, {}) + expect(wrapper.vm.$el.parentNode).to.be.null + wrapper.destroy() + }) + it('attaches to a provided HTMLElement', () => { + const div = document.createElement('div') + div.id = 'root' + document.body.appendChild(div) + expect(document.getElementById('root')).to.not.be.null + expect(document.getElementById('attach-to')).to.be.null + const wrapper = mountingMethod(TestComponent, { + attachTo: div + }) + + const root = document.getElementById('root') + const rendered = document.getElementById('attach-to') + expect(wrapper.vm.$el.parentNode).to.not.be.null + expect(root).to.be.null + expect(rendered).to.not.be.null + expect(rendered.outerHTML).to.equal(outerHTML) + expect(wrapper.options.attachedToDocument).to.equal(true) + wrapper.destroy() + expect(document.getElementById('attach-to')).to.be.null + }) + it('attaches to a provided CSS selector string', () => { + const div = document.createElement('div') + div.id = 'root' + document.body.appendChild(div) + expect(document.getElementById('root')).to.not.be.null + expect(document.getElementById('attach-to')).to.be.null + const wrapper = mountingMethod(TestComponent, { + attachTo: '#root' + }) + + const root = document.getElementById('root') + const rendered = document.getElementById('attach-to') + expect(wrapper.vm.$el.parentNode).to.not.be.null + expect(root).to.be.null + expect(rendered).to.not.be.null + expect(rendered.outerHTML).to.equal(outerHTML) + expect(wrapper.options.attachedToDocument).to.equal(true) + wrapper.destroy() + expect(document.getElementById('attach-to')).to.be.null + }) + + it('correctly hydrates markup', () => { + expect(document.getElementById('attach-to')).to.be.null + + const div = document.createElement('div') + div.id = 'attach-to' + div.setAttribute('data-server-rendered', 'true') + div.innerHTML = innerHTML + document.body.appendChild(div) + expect(div.outerHTML).to.equal(ssrHTML) + const wrapper = mountingMethod(TestComponent, { + attachTo: '#attach-to' + }) + + const rendered = document.getElementById('attach-to') + expect(wrapper.vm.$el.parentNode).to.not.be.null + expect(rendered).to.not.be.null + expect(rendered.outerHTML).to.equal(outerHTML) + expect(wrapper.options.attachedToDocument).to.equal(true) + wrapper.destroy() + expect(document.getElementById('attach-to')).to.be.null + }) +})