Skip to content

Commit

Permalink
feat: add support of arbitrary mounting point via attachTo option (#1492
Browse files Browse the repository at this point in the history
)

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
  • Loading branch information
jnields authored Apr 10, 2020
1 parent 4b0c5c9 commit f3d0d3f
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 8 deletions.
5 changes: 4 additions & 1 deletion docs/api/mount.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
```
Expand Down
32 changes: 31 additions & 1 deletion docs/api/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: '<div>ABC</div>',
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.
Expand Down
6 changes: 5 additions & 1 deletion docs/api/shallowMount.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- `{Component} component`
- `{Object} options`
- `{HTMLElement|string} string`
- `{boolean} attachToDocument`
- `{Object} context`
- `{Array<Component|Object>|Component} children`
Expand Down Expand Up @@ -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()
})
})
```
Expand Down
2 changes: 1 addition & 1 deletion docs/api/wrapper/destroy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions flow/options.flow.js
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -17,6 +18,7 @@ declare type Options = {
}

declare type NormalizedOptions = {
attachTo?: HTMLElement | string,
attachToDocument?: boolean,
propsData?: Object,
mocks: Object,
Expand Down
20 changes: 18 additions & 2 deletions packages/shared/validate-options.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions packages/test-utils/src/mount.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ 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 = {}

throwIfInstancesThrew(vm)

const wrapperOptions = {
attachedToDocument: !!mergedOptions.attachToDocument
attachedToDocument: !!el
}

const root = parentVm.$options._isFunctionalContainer
Expand Down
2 changes: 2 additions & 0 deletions test/specs/mount.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -366,6 +367,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => {
' <p class="prop-2"></p>\n' +
'</div>'
)
wrapper.destroy()
})

it('overwrites the component options with the instance options', () => {
Expand Down
77 changes: 77 additions & 0 deletions test/specs/mounting-options/attachTo.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describeWithShallowAndMount } from '~resources/utils'

const innerHTML = '<input><span>Hello world</span>'
const outerHTML = `<div id="attach-to">${innerHTML}</div>`
const ssrHTML = `<div id="attach-to" data-server-rendered="true">${innerHTML}</div>`
const template = '<div id="attach-to"><input /><span>Hello world</span></div>'
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
})
})

0 comments on commit f3d0d3f

Please sign in to comment.