diff --git a/docs/API.md b/docs/API.md index 6f975316e..38e0186f9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -348,6 +348,85 @@ test('findAll', () => { }) ``` +### `findComponent` + +Finds a Vue Component instance and returns a `VueWrapper` if one is found, otherwise returns `ErrorWrapper`. + +**Supported syntax:** + +* **querySelector** - `findComponent('.component')` - Matches standard query selector. +* **Name** - `findComponent({ name: 'myComponent' })` - matches PascalCase, snake-case, camelCase +* **ref** - `findComponent({ ref: 'dropdown' })` - Can be used only on direct ref children of mounted component +* **SFC** - `findComponent(ImportedComponent)` - Pass an imported component directly. + +```vue + + +``` + +```vue + +``` + +```js +test('find', () => { + const wrapper = mount(Component) + + wrapper.find('.foo') //=> found; returns VueWrapper + wrapper.find('[data-test="foo"]') //=> found; returns VueWrapper + wrapper.find({ name: 'Foo' }) //=> found; returns VueWrapper + wrapper.find({ name: 'foo' }) //=> found; returns VueWrapper + wrapper.find({ ref: 'foo' }) //=> found; returns VueWrapper + wrapper.find(Foo) //=> found; returns VueWrapper +}) +``` + +### `findAllComponents` + +Similar to `findComponent` but finds all Vue Component instances that match the query and returns an array of `VueWrapper`. + +**Supported syntax:** + + * **querySelector** - `findAllComponents('.component')` + * **Name** - `findAllComponents({ name: 'myComponent' })` + * **SFC** - `findAllComponents(ImportedComponent)` + +**Note** - `Ref` is not supported here. + + +```vue + +``` + +```js +test('findAllComponents', () => { + const wrapper = mount(Component) + + wrapper.findAllComponents('[data-test="number"]') //=> found; returns array of VueWrapper +}) +``` + ### `trigger` Simulates an event, for example `click`, `submit` or `keyup`. Since events often cause a re-render, `trigger` returs `Vue.nextTick`. If you expect the event to trigger a re-render, you should use `await` when you call `trigger` to ensure that Vue updates the DOM before you make an assertion. diff --git a/src/constants.ts b/src/constants.ts index 2a7337060..a48a34fab 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,3 @@ export const MOUNT_ELEMENT_ID = 'app' +export const MOUNT_COMPONENT_REF = 'VTU_COMPONENT' +export const MOUNT_PARENT_NAME = 'VTU_ROOT' diff --git a/src/emitMixin.ts b/src/emitMixin.ts index 3688a0e6e..6da5677c8 100644 --- a/src/emitMixin.ts +++ b/src/emitMixin.ts @@ -1,10 +1,11 @@ import { getCurrentInstance } from 'vue' -export const createEmitMixin = () => { - const events: Record = {} - - const emitMixin = { +export const attachEmitListener = () => { + return { beforeCreate() { + let events: Record = {} + this.__emitted = events + getCurrentInstance().emit = (event: string, ...args: unknown[]) => { events[event] ? (events[event] = [...events[event], [...args]]) @@ -14,9 +15,4 @@ export const createEmitMixin = () => { } } } - - return { - events, - emitMixin - } } diff --git a/src/error-wrapper.ts b/src/error-wrapper.ts index 86d72c625..d6ff8a4f9 100644 --- a/src/error-wrapper.ts +++ b/src/error-wrapper.ts @@ -1,9 +1,11 @@ +import { FindComponentSelector } from './types' + interface Options { - selector: string + selector: FindComponentSelector } export class ErrorWrapper { - selector: string + selector: FindComponentSelector element: null constructor({ selector }: Options) { @@ -14,6 +16,10 @@ export class ErrorWrapper { return Error(`Cannot call ${method} on an empty wrapper.`) } + vm(): Error { + throw this.wrapperError('vm') + } + attributes() { throw this.wrapperError('attributes') } @@ -34,8 +40,8 @@ export class ErrorWrapper { throw this.wrapperError('findAll') } - setChecked() { - throw this.wrapperError('setChecked') + setProps() { + throw this.wrapperError('setProps') } setValue() { diff --git a/src/mount.ts b/src/mount.ts index 684cd09a3..a125b86d4 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -14,9 +14,13 @@ import { } from 'vue' import { createWrapper, VueWrapper } from './vue-wrapper' -import { createEmitMixin } from './emitMixin' +import { attachEmitListener } from './emitMixin' import { createDataMixin } from './dataMixin' -import { MOUNT_ELEMENT_ID } from './constants' +import { + MOUNT_COMPONENT_REF, + MOUNT_ELEMENT_ID, + MOUNT_PARENT_NAME +} from './constants' import { stubComponents } from './stubs' type Slot = VNode | string | { render: Function } @@ -86,11 +90,11 @@ export function mount( // we define props as reactive so that way when we update them with `setProps` // Vue's reactivity system will cause a rerender. - const props = reactive({ ...options?.props, ref: 'VTU_COMPONENT' }) + const props = reactive({ ...options?.props, ref: MOUNT_COMPONENT_REF }) // create the wrapper component const Parent = defineComponent({ - name: 'VTU_COMPONENT', + name: MOUNT_PARENT_NAME, render() { return h(component, props, slots) } @@ -149,8 +153,7 @@ export function mount( } // add tracking for emitted events - const { emitMixin, events } = createEmitMixin() - vm.mixin(emitMixin) + vm.mixin(attachEmitListener()) // stubs if (options?.global?.stubs) { @@ -161,6 +164,6 @@ export function mount( // mount the app! const app = vm.mount(el) - - return createWrapper(app, events, setProps) + const App = app.$refs[MOUNT_COMPONENT_REF] as ComponentPublicInstance + return createWrapper(App, setProps) } diff --git a/src/types.ts b/src/types.ts index d1e8f2cff..184494a67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,3 +12,14 @@ export interface WrapperAPI { text: () => string trigger: (eventString: string) => Promise<(fn?: () => void) => Promise> } + +interface RefSelector { + ref: string +} + +interface NameSelector { + name: string +} + +export type FindComponentSelector = RefSelector | NameSelector | string +export type FindAllComponentsSelector = NameSelector | string diff --git a/src/utils/find.ts b/src/utils/find.ts new file mode 100644 index 000000000..2ebf7ada3 --- /dev/null +++ b/src/utils/find.ts @@ -0,0 +1,61 @@ +import { VNode, ComponentPublicInstance } from 'vue' +import { FindAllComponentsSelector } from '../types' +import { matchName } from './matchName' + +/** + * Detect whether a selector matches a VNode + * @param node + * @param selector + * @return {boolean | ((value: any) => boolean)} + */ +function matches(node: VNode, selector: FindAllComponentsSelector): boolean { + // do not return none Vue components + if (!node.component) return false + + if (typeof selector === 'string') { + return node.el?.matches?.(selector) + } + + if (typeof selector === 'object' && typeof node.type === 'object') { + if (selector.name && ('name' in node.type || 'displayName' in node.type)) { + // match normal component definitions or functional components + return matchName(selector.name, node.type.name || node.type.displayName) + } + } + + return false +} + +/** + * Collect all children + * @param nodes + * @param children + */ +function aggregateChildren(nodes, children) { + if (children && Array.isArray(children)) { + ;[...children].reverse().forEach((n: VNode) => { + nodes.unshift(n) + }) + } +} + +function findAllVNodes(vnode: VNode, selector: any): VNode[] { + const matchingNodes = [] + const nodes = [vnode] + while (nodes.length) { + const node = nodes.shift() + aggregateChildren(nodes, node.children) + aggregateChildren(nodes, node.component?.subTree.children) + if (matches(node, selector)) { + matchingNodes.push(node) + } + } + + return matchingNodes +} + +export function find(root: VNode, selector: any): ComponentPublicInstance[] { + return findAllVNodes(root, selector).map( + (vnode: VNode) => vnode.component.proxy + ) +} diff --git a/src/utils/matchName.ts b/src/utils/matchName.ts new file mode 100644 index 000000000..be2a4395c --- /dev/null +++ b/src/utils/matchName.ts @@ -0,0 +1,14 @@ +import { camelize, capitalize } from '@vue/shared' + +export function matchName(target, sourceName) { + const camelized = camelize(target) + const capitalized = capitalize(camelized) + + return ( + sourceName && + (sourceName === target || + sourceName === camelized || + sourceName === capitalized || + capitalize(camelize(sourceName)) === capitalized) + ) +} diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index 0e857cca7..39748a00e 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -2,44 +2,41 @@ import { ComponentPublicInstance, nextTick } from 'vue' import { ShapeFlags } from '@vue/shared' import { DOMWrapper } from './dom-wrapper' -import { WrapperAPI } from './types' +import { + FindAllComponentsSelector, + FindComponentSelector, + WrapperAPI +} from './types' import { ErrorWrapper } from './error-wrapper' -import { MOUNT_ELEMENT_ID } from './constants' +import { find } from './utils/find' export class VueWrapper implements WrapperAPI { private componentVM: T - private __emitted: Record = {} - private __vm: ComponentPublicInstance + private rootVM: ComponentPublicInstance private __setProps: (props: Record) => void constructor( vm: ComponentPublicInstance, - events: Record, - setProps: (props: Record) => void + setProps?: (props: Record) => void ) { - this.__vm = vm + this.rootVM = vm.$root + this.componentVM = vm as T this.__setProps = setProps - this.componentVM = this.__vm.$refs['VTU_COMPONENT'] as T - this.__emitted = events - } - - private get appRootNode() { - return document.getElementById(MOUNT_ELEMENT_ID) as HTMLDivElement } private get hasMultipleRoots(): boolean { // if the subtree is an array of children, we have multiple root nodes - return this.componentVM.$.subTree.shapeFlag === ShapeFlags.ARRAY_CHILDREN + return this.vm.$.subTree.shapeFlag === ShapeFlags.ARRAY_CHILDREN } private get parentElement(): Element { - return this.componentVM.$el.parentElement + return this.vm.$el.parentElement } get element(): Element { // if the component has multiple root elements, we use the parent's element - return this.hasMultipleRoots ? this.parentElement : this.componentVM.$el + return this.hasMultipleRoots ? this.parentElement : this.vm.$el } get vm(): T { @@ -64,12 +61,14 @@ export class VueWrapper return true } - emitted() { - return this.__emitted + emitted(): Record { + // TODO Should we define this? + // @ts-ignore + return this.vm.__emitted } html() { - return this.appRootNode.innerHTML + return this.parentElement.innerHTML } text() { @@ -95,12 +94,29 @@ export class VueWrapper return result } + findComponent(selector: FindComponentSelector): VueWrapper | ErrorWrapper { + if (typeof selector === 'object' && 'ref' in selector) { + return createWrapper(this.vm.$refs[selector.ref] as T) + } + const result = find(this.vm.$.subTree, selector) + if (!result.length) return new ErrorWrapper({ selector }) + return createWrapper(result[0]) + } + + findAllComponents(selector: FindAllComponentsSelector): VueWrapper[] { + return find(this.vm.$.subTree, selector).map((c) => createWrapper(c)) + } + findAll(selector: string): DOMWrapper[] { - const results = this.appRootNode.querySelectorAll(selector) + const results = this.parentElement.querySelectorAll(selector) return Array.from(results).map((x) => new DOMWrapper(x)) } - setProps(props: Record) { + setProps(props: Record): Promise { + // if this VM's parent is not the root, error out + if (this.vm.$parent !== this.rootVM) { + throw Error('You can only use setProps on your mounted component') + } this.__setProps(props) return nextTick() } @@ -113,8 +129,7 @@ export class VueWrapper export function createWrapper( vm: ComponentPublicInstance, - events: Record, - setProps: (props: Record) => void + setProps?: (props: Record) => void ): VueWrapper { - return new VueWrapper(vm, events, setProps) + return new VueWrapper(vm, setProps) } diff --git a/tests/find.spec.ts b/tests/find.spec.ts index 6caab72a1..a4b51f147 100644 --- a/tests/find.spec.ts +++ b/tests/find.spec.ts @@ -2,6 +2,7 @@ import { defineComponent, h } from 'vue' import { mount } from '../src' import SuspenseComponent from './components/Suspense.vue' +import Hello from './components/Hello.vue' describe('find', () => { it('find using single root node', () => { diff --git a/tests/findAllComponents.spec.ts b/tests/findAllComponents.spec.ts new file mode 100644 index 000000000..b13e65460 --- /dev/null +++ b/tests/findAllComponents.spec.ts @@ -0,0 +1,28 @@ +import { mount } from '../src' +import Hello from './components/Hello.vue' +import { defineComponent } from 'vue' + +const compC = defineComponent({ + name: 'ComponentC', + template: '
C
' +}) +const compB = defineComponent({ + template: '
TextBeforeTextAfter
', + components: { compC } +}) +const compA = defineComponent({ + template: '
', + components: { compB, Hello } +}) + +describe('findAllComponents', () => { + it('finds all deeply nested vue components', () => { + const wrapper = mount(compA) + // find by DOM selector + expect(wrapper.findAllComponents('.C')).toHaveLength(2) + expect(wrapper.findAllComponents({ name: 'Hello' })[0].text()).toBe( + 'Hello world' + ) + expect(wrapper.findAllComponents(Hello)[0].text()).toBe('Hello world') + }) +}) diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts new file mode 100644 index 000000000..499ea1abf --- /dev/null +++ b/tests/findComponent.spec.ts @@ -0,0 +1,89 @@ +import { defineComponent } from 'vue' +import { mount } from '../src' +import Hello from './components/Hello.vue' + +const compC = defineComponent({ + name: 'ComponentC', + template: '
C
' +}) + +const compD = defineComponent({ + name: 'ComponentD', + template: '', + components: { compC } +}) + +const compB = defineComponent({ + name: 'component-b', + template: ` +
+ TextBefore + + TextAfter + + +
`, + components: { compC, compD } +}) + +const compA = defineComponent({ + template: ` +
+ + +
+
`, + components: { compB, Hello } +}) + +describe('findComponent', () => { + it('does not find plain dom elements', () => { + const wrapper = mount(compA) + expect(wrapper.findComponent('.domElement').exists()).toBeFalsy() + }) + + it('finds component by ref', () => { + const wrapper = mount(compA) + // find by ref + expect(wrapper.findComponent({ ref: 'b' })).toBeTruthy() + }) + + it('finds component by dom selector', () => { + const wrapper = mount(compA) + // find by DOM selector + expect(wrapper.findComponent('.C').vm).toHaveProperty( + '$options.name', + 'ComponentC' + ) + }) + + it('does allows using complicated DOM selector query', () => { + const wrapper = mount(compA) + expect(wrapper.findComponent('.B > .C').vm).toHaveProperty( + '$options.name', + 'ComponentC' + ) + }) + + it('finds a component when root of mounted component', async () => { + const wrapper = mount(compD) + // make sure it finds the component, not its root + expect(wrapper.findComponent('.c-as-root-on-d').vm).toHaveProperty( + '$options.name', + 'ComponentC' + ) + }) + + it('finds component by name', () => { + const wrapper = mount(compA) + expect(wrapper.findComponent({ name: 'Hello' }).text()).toBe('Hello world') + expect(wrapper.findComponent({ name: 'ComponentB' }).exists()).toBeTruthy() + expect(wrapper.findComponent({ name: 'component-c' }).exists()).toBeTruthy() + }) + + it('finds component by imported SFC file', () => { + const wrapper = mount(compA) + expect(wrapper.findComponent(Hello).text()).toBe('Hello world') + expect(wrapper.findComponent(compC).text()).toBe('C') + }) +}) diff --git a/tests/setProps.spec.ts b/tests/setProps.spec.ts index c1ffb81fc..cc6241cdf 100644 --- a/tests/setProps.spec.ts +++ b/tests/setProps.spec.ts @@ -10,10 +10,10 @@ describe('setProps', () => { } const wrapper = mount(Foo, { props: { - foo: 'foo' + foo: 'bar' } }) - expect(wrapper.html()).toContain('foo') + expect(wrapper.html()).toContain('bar') await wrapper.setProps({ foo: 'qux' }) expect(wrapper.html()).toContain('qux') @@ -44,7 +44,8 @@ describe('setProps', () => { it('sets component props, and updates DOM when props were not initially passed', async () => { const Foo = { props: ['foo'], - template: `
{{ foo }}
` + template: ` +
{{ foo }}
` } const wrapper = mount(Foo) expect(wrapper.html()).not.toContain('foo') @@ -67,7 +68,8 @@ describe('setProps', () => { this.bar = val } }, - template: `
{{ bar }}
` + template: ` +
{{ bar }}
` } const wrapper = mount(Foo) expect(wrapper.html()).toContain('original-bar') @@ -118,4 +120,27 @@ describe('setProps', () => { expect(wrapper.attributes()).toEqual(nonExistentProp) expect(wrapper.html()).toBe('
foo
') }) + + it('allows using only on mounted component', async () => { + const Foo = { + name: 'Foo', + props: ['foo'], + template: '
{{ foo }}
' + } + const Baz = { + props: ['baz'], + template: '
', + components: { Foo } + } + + const wrapper = mount(Baz, { + props: { + baz: 'baz' + } + }) + const FooResult = wrapper.findComponent({ name: 'Foo' }) + expect(() => FooResult.setProps({ baz: 'bin' })).toThrowError( + 'You can only use setProps on your mounted component' + ) + }) }) diff --git a/tests/vm.spec.ts b/tests/vm.spec.ts index 2613b3f0b..6f2a3b31d 100644 --- a/tests/vm.spec.ts +++ b/tests/vm.spec.ts @@ -5,6 +5,7 @@ import { mount } from '../src' describe('vm', () => { it('returns the component vm', () => { const Component = defineComponent({ + name: 'VTUComponent', template: '
{{ msg }}
', setup() { const msg = 'hello' diff --git a/yarn.lock b/yarn.lock index 221472f4c..c8ebd7738 100644 --- a/yarn.lock +++ b/yarn.lock @@ -268,10 +268,10 @@ dependencies: "@babel/types" "^7.8.3" -"@babel/helper-validator-identifier@^7.9.0": - version "7.9.0" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz#ad53562a7fc29b3b9a91bbf7d10397fd146346ed" - integrity sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw== +"@babel/helper-validator-identifier@^7.9.0", "@babel/helper-validator-identifier@^7.9.5": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" + integrity sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g== "@babel/helper-wrap-function@^7.8.3": version "7.8.3" @@ -830,7 +830,16 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" -"@babel/types@^7.8.6", "@babel/types@^7.9.0": +"@babel/types@^7.8.6": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.5.tgz#89231f82915a8a566a703b3b20133f73da6b9444" + integrity sha512-XjnvNqenk818r5zMaba+sLQjnbda31UfUURv3ei0qPQw4u+j2jMyJ5b11y8ZHYTRSI3NnInQkkkRT4fLqqPdHg== + dependencies: + "@babel/helper-validator-identifier" "^7.9.5" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + +"@babel/types@^7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.0.tgz#00b064c3df83ad32b2dbf5ff07312b15c7f1efb5" integrity sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng== @@ -2097,9 +2106,9 @@ cssstyle@^2.0.0: cssom "~0.3.6" csstype@^2.6.8: - version "2.6.9" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" - integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q== + version "2.6.10" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" + integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== currently-unhandled@^0.4.1: version "0.4.1"