From b7fae3c7f81e116de8778ce0bded45493f66477a Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Thu, 2 Apr 2020 17:43:19 +0300 Subject: [PATCH 01/10] feat: basic searching for nested component instances --- src/utils/find.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/vue-wrapper.ts | 8 ++++++++ tests/find.spec.ts | 23 +++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 src/utils/find.ts diff --git a/src/utils/find.ts b/src/utils/find.ts new file mode 100644 index 000000000..73d575266 --- /dev/null +++ b/src/utils/find.ts @@ -0,0 +1,42 @@ +import { VNode } from 'vue/dist/vue' + +function matches(node: VNode, selector): boolean { + if (typeof selector === 'string') { + return node.el?.matches?.(selector) + } + if (typeof selector === 'object') { + if (selector.name && typeof node.type === 'object') { + // @ts-ignore + return node.type.name === selector.name + } + } + return false +} + +function aggregateChildren(nodes, children) { + if (children && Array.isArray(children)) { + ;[...children].reverse().forEach((n: VNode) => { + nodes.unshift(n) + }) + } +} + +function findAllVNodes(vnode: VNode, selector: any) { + 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) { + const result = findAllVNodes(root, selector) + return result.length ? result[0] : undefined +} diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index 4b22a33ac..f6cb5f3eb 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -5,6 +5,7 @@ import { DOMWrapper } from './dom-wrapper' import { WrapperAPI } from './types' import { ErrorWrapper } from './error-wrapper' import { MOUNT_ELEMENT_ID } from './constants' +import { find } from './utils/find' export class VueWrapper implements WrapperAPI { @@ -89,6 +90,13 @@ export class VueWrapper return result } + findByComponent(selector: { ref?: string; name?: string } | string): any { + if (typeof selector === 'object' && selector.ref) { + return this.componentVM.$refs[selector.ref] + } + return find(this.componentVM.$.subTree, selector) + } + findAll(selector: string): DOMWrapper[] { const results = this.appRootNode.querySelectorAll(selector) return Array.from(results).map((x) => new DOMWrapper(x)) diff --git a/tests/find.spec.ts b/tests/find.spec.ts index 6caab72a1..f37c3602a 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', () => { @@ -43,6 +44,28 @@ describe('find', () => { expect(wrapper.html()).toContain('Fallback content') expect(wrapper.find('div').exists()).toBeTruthy() }) + + it('finds deeply nested vue components', () => { + const compC = { + template: '
C
' + } + const compB = { + template: '
TextBeforeTextAfter
', + components: { compC } + } + const compA = { + template: '
', + components: { compB, Hello } + } + const wrapper = mount(compA) + // find by ref + expect(wrapper.findByComponent({ ref: 'b' })).toBeTruthy() + // find by DOM selector + expect(wrapper.findByComponent('.C').el.textContent).toEqual('C') + expect(wrapper.findByComponent({ name: 'Hello' }).el.textContent).toBe( + 'Hello world' + ) + }) }) describe('findAll', () => { From 49885393ea72effd5bc5633f38c7a067717c1539 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Sat, 11 Apr 2020 18:36:23 +0300 Subject: [PATCH 02/10] refactor: refactor findComponent and findAllComponents --- src/utils/find.ts | 3 +-- src/vue-wrapper.ts | 20 ++++++++++++++++++-- tests/find.spec.ts | 22 ---------------------- tests/findAllComponents.spec.ts | 29 +++++++++++++++++++++++++++++ tests/findComponent.spec.ts | 28 ++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 26 deletions(-) create mode 100644 tests/findAllComponents.spec.ts create mode 100644 tests/findComponent.spec.ts diff --git a/src/utils/find.ts b/src/utils/find.ts index 73d575266..94df498f9 100644 --- a/src/utils/find.ts +++ b/src/utils/find.ts @@ -37,6 +37,5 @@ function findAllVNodes(vnode: VNode, selector: any) { } export function find(root: VNode, selector: any) { - const result = findAllVNodes(root, selector) - return result.length ? result[0] : undefined + return findAllVNodes(root, selector) } diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index f6cb5f3eb..c4ab61513 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -7,6 +7,17 @@ import { ErrorWrapper } from './error-wrapper' import { MOUNT_ELEMENT_ID } from './constants' import { find } from './utils/find' +interface RefSelector { + ref: string +} + +interface NameSelector { + name: string +} + +type FindComponentSelector = RefSelector | NameSelector | string +type FindAllComponentsSelector = NameSelector | string + export class VueWrapper implements WrapperAPI { private componentVM: T @@ -90,10 +101,15 @@ export class VueWrapper return result } - findByComponent(selector: { ref?: string; name?: string } | string): any { - if (typeof selector === 'object' && selector.ref) { + findComponent(selector: FindComponentSelector): any { + if (typeof selector === 'object' && 'ref' in selector) { return this.componentVM.$refs[selector.ref] } + const result = find(this.componentVM.$.subTree, selector) + return result.length ? result[0] : result + } + + findAllComponents(selector: FindAllComponentsSelector): any[] { return find(this.componentVM.$.subTree, selector) } diff --git a/tests/find.spec.ts b/tests/find.spec.ts index f37c3602a..a4b51f147 100644 --- a/tests/find.spec.ts +++ b/tests/find.spec.ts @@ -44,28 +44,6 @@ describe('find', () => { expect(wrapper.html()).toContain('Fallback content') expect(wrapper.find('div').exists()).toBeTruthy() }) - - it('finds deeply nested vue components', () => { - const compC = { - template: '
C
' - } - const compB = { - template: '
TextBeforeTextAfter
', - components: { compC } - } - const compA = { - template: '
', - components: { compB, Hello } - } - const wrapper = mount(compA) - // find by ref - expect(wrapper.findByComponent({ ref: 'b' })).toBeTruthy() - // find by DOM selector - expect(wrapper.findByComponent('.C').el.textContent).toEqual('C') - expect(wrapper.findByComponent({ name: 'Hello' }).el.textContent).toBe( - 'Hello world' - ) - }) }) describe('findAll', () => { diff --git a/tests/findAllComponents.spec.ts b/tests/findAllComponents.spec.ts new file mode 100644 index 000000000..877056ed2 --- /dev/null +++ b/tests/findAllComponents.spec.ts @@ -0,0 +1,29 @@ +import { mount } from '../src' +import Hello from './components/Hello.vue' + +const compC = { + name: 'ComponentC', + template: '
C
' +} +const compB = { + template: '
TextBeforeTextAfter
', + components: { compC } +} +const compA = { + 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].el.textContent).toBe( + 'Hello world' + ) + expect(wrapper.findAllComponents(Hello)[0].el.textContent).toBe( + 'Hello world' + ) + }) +}) diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts new file mode 100644 index 000000000..dc3344bc4 --- /dev/null +++ b/tests/findComponent.spec.ts @@ -0,0 +1,28 @@ +import { mount } from '../src' +import Hello from './components/Hello.vue' + +describe('findComponent', () => { + it('finds deeply nested vue components', () => { + const compC = { + name: 'ComponentC', + template: '
C
' + } + const compB = { + template: '
TextBeforeTextAfter
', + components: { compC } + } + const compA = { + template: '
', + components: { compB, Hello } + } + const wrapper = mount(compA) + // find by ref + expect(wrapper.findComponent({ ref: 'b' })).toBeTruthy() + // find by DOM selector + expect(wrapper.findComponent('.C').type.name).toEqual('ComponentC') + expect(wrapper.findComponent({ name: 'Hello' }).el.textContent).toBe( + 'Hello world' + ) + expect(wrapper.findComponent(Hello).el.textContent).toBe('Hello world') + }) +}) From 212ca200fc4a2d156142f2d7ee8387055960e41a Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Mon, 13 Apr 2020 01:22:38 +0300 Subject: [PATCH 03/10] feat: return ComponentPublicInstance from findComponent --- src/utils/find.ts | 10 ++++++---- src/vue-wrapper.ts | 14 ++++++++------ tests/findComponent.spec.ts | 6 +++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/utils/find.ts b/src/utils/find.ts index 94df498f9..68cc76a2a 100644 --- a/src/utils/find.ts +++ b/src/utils/find.ts @@ -1,4 +1,4 @@ -import { VNode } from 'vue/dist/vue' +import { VNode, ComponentPublicInstance } from 'vue' function matches(node: VNode, selector): boolean { if (typeof selector === 'string') { @@ -21,7 +21,7 @@ function aggregateChildren(nodes, children) { } } -function findAllVNodes(vnode: VNode, selector: any) { +function findAllVNodes(vnode: VNode, selector: any): VNode[] { const matchingNodes = [] const nodes = [vnode] while (nodes.length) { @@ -36,6 +36,8 @@ function findAllVNodes(vnode: VNode, selector: any) { return matchingNodes } -export function find(root: VNode, selector: any) { - return findAllVNodes(root, selector) +export function find(root: VNode, selector: any): ComponentPublicInstance[] { + return findAllVNodes(root, selector).map( + (vnode: VNode) => vnode.component.proxy + ) } diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index c4ab61513..b277ac773 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -75,7 +75,7 @@ export class VueWrapper } html() { - return this.appRootNode.innerHTML + return this.parentElement.innerHTML } text() { @@ -101,20 +101,22 @@ export class VueWrapper return result } - findComponent(selector: FindComponentSelector): any { + findComponent(selector: FindComponentSelector): ComponentPublicInstance { if (typeof selector === 'object' && 'ref' in selector) { - return this.componentVM.$refs[selector.ref] + return this.componentVM.$refs[selector.ref] as ComponentPublicInstance } const result = find(this.componentVM.$.subTree, selector) - return result.length ? result[0] : result + return result.length ? result[0] : undefined } - findAllComponents(selector: FindAllComponentsSelector): any[] { + findAllComponents( + selector: FindAllComponentsSelector + ): ComponentPublicInstance[] { return find(this.componentVM.$.subTree, selector) } 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)) } diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts index dc3344bc4..96d816cf6 100644 --- a/tests/findComponent.spec.ts +++ b/tests/findComponent.spec.ts @@ -19,10 +19,10 @@ describe('findComponent', () => { // find by ref expect(wrapper.findComponent({ ref: 'b' })).toBeTruthy() // find by DOM selector - expect(wrapper.findComponent('.C').type.name).toEqual('ComponentC') - expect(wrapper.findComponent({ name: 'Hello' }).el.textContent).toBe( + expect(wrapper.findComponent('.C').$options.name).toEqual('ComponentC') + expect(wrapper.findComponent({ name: 'Hello' }).$el.textContent).toBe( 'Hello world' ) - expect(wrapper.findComponent(Hello).el.textContent).toBe('Hello world') + expect(wrapper.findComponent(Hello).$el.textContent).toBe('Hello world') }) }) From a8269a6b21b654dd69fb73d480e2fc58e5673db7 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Tue, 14 Apr 2020 22:02:02 +0300 Subject: [PATCH 04/10] refactor: update find matching methods --- src/types.ts | 11 ++++++++++ src/utils/find.ts | 12 ++++++----- src/utils/matchName.ts | 14 ++++++++++++ src/vue-wrapper.ts | 17 +++++---------- tests/findComponent.spec.ts | 43 ++++++++++++++++++++++++++----------- 5 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 src/utils/matchName.ts 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 index 68cc76a2a..535bccf9c 100644 --- a/src/utils/find.ts +++ b/src/utils/find.ts @@ -1,13 +1,15 @@ import { VNode, ComponentPublicInstance } from 'vue' +import { FindAllComponentsSelector } from '../types' +import { matchName } from './matchName' -function matches(node: VNode, selector): boolean { +function matches(node: VNode, selector: FindAllComponentsSelector): boolean { if (typeof selector === 'string') { return node.el?.matches?.(selector) } - if (typeof selector === 'object') { - if (selector.name && typeof node.type === 'object') { - // @ts-ignore - return node.type.name === selector.name + 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 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 b277ac773..404dc67a3 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -2,22 +2,15 @@ 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' -interface RefSelector { - ref: string -} - -interface NameSelector { - name: string -} - -type FindComponentSelector = RefSelector | NameSelector | string -type FindAllComponentsSelector = NameSelector | string - export class VueWrapper implements WrapperAPI { private componentVM: T diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts index 96d816cf6..b2a07fecc 100644 --- a/tests/findComponent.spec.ts +++ b/tests/findComponent.spec.ts @@ -1,28 +1,45 @@ import { mount } from '../src' import Hello from './components/Hello.vue' +const compC = { + name: 'ComponentC', + template: '
C
' +} +const compB = { + name: 'component-b', + template: '
TextBeforeTextAfter
', + components: { compC } +} +const compA = { + template: '
', + components: { compB, Hello } +} + describe('findComponent', () => { - it('finds deeply nested vue components', () => { - const compC = { - name: 'ComponentC', - template: '
C
' - } - const compB = { - template: '
TextBeforeTextAfter
', - components: { compC } - } - const compA = { - template: '
', - components: { compB, Hello } - } + 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').$options.name).toEqual('ComponentC') + }) + + it('finds component by name', () => { + const wrapper = mount(compA) expect(wrapper.findComponent({ name: 'Hello' }).$el.textContent).toBe( 'Hello world' ) + expect(wrapper.findComponent({ name: 'ComponentB' })).toBeTruthy() + expect(wrapper.findComponent({ name: 'component-c' })).toBeTruthy() + }) + + it('finds component by imported SFC file', () => { + const wrapper = mount(compA) expect(wrapper.findComponent(Hello).$el.textContent).toBe('Hello world') + expect(wrapper.findComponent(compC).$el.textContent).toBe('C') }) }) From 92de550c4fe59c32746a048ba0a626271450c033 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Tue, 14 Apr 2020 23:32:57 +0300 Subject: [PATCH 05/10] tests: improve findComponent tests --- src/utils/find.ts | 16 ++++++++++++ tests/findAllComponents.spec.ts | 8 +++--- tests/findComponent.spec.ts | 43 ++++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/utils/find.ts b/src/utils/find.ts index 535bccf9c..2ebf7ada3 100644 --- a/src/utils/find.ts +++ b/src/utils/find.ts @@ -2,19 +2,35 @@ 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) => { diff --git a/tests/findAllComponents.spec.ts b/tests/findAllComponents.spec.ts index 877056ed2..a2a362e7c 100644 --- a/tests/findAllComponents.spec.ts +++ b/tests/findAllComponents.spec.ts @@ -19,10 +19,10 @@ describe('findAllComponents', () => { const wrapper = mount(compA) // find by DOM selector expect(wrapper.findAllComponents('.C')).toHaveLength(2) - expect(wrapper.findAllComponents({ name: 'Hello' })[0].el.textContent).toBe( - 'Hello world' - ) - expect(wrapper.findAllComponents(Hello)[0].el.textContent).toBe( + expect( + wrapper.findAllComponents({ name: 'Hello' })[0].$el.textContent + ).toBe('Hello world') + expect(wrapper.findAllComponents(Hello)[0].$el.textContent).toBe( 'Hello world' ) }) diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts index b2a07fecc..e2d1e854c 100644 --- a/tests/findComponent.spec.ts +++ b/tests/findComponent.spec.ts @@ -5,17 +5,41 @@ const compC = { name: 'ComponentC', template: '
C
' } + +const compD = { + name: 'ComponentD', + template: '', + components: { compC } +} + const compB = { name: 'component-b', - template: '
TextBeforeTextAfter
', - components: { compC } + template: ` +
+ TextBefore + + TextAfter + + +
`, + components: { compC, compD } } const compA = { - template: '
', + template: ` +
+ + +
+
`, components: { compB, Hello } } describe('findComponent', () => { + it('does not find plain dom elements', () => { + const wrapper = mount(compA) + expect(wrapper.findComponent('.domElement')).toBeFalsy() + }) + it('finds component by ref', () => { const wrapper = mount(compA) // find by ref @@ -28,6 +52,19 @@ describe('findComponent', () => { expect(wrapper.findComponent('.C').$options.name).toEqual('ComponentC') }) + it('does allows using complicated DOM selector query', () => { + const wrapper = mount(compA) + expect(wrapper.findComponent('.B > .C').$options.name).toEqual('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').$options.name).toEqual( + 'ComponentC' + ) + }) + it('finds component by name', () => { const wrapper = mount(compA) expect(wrapper.findComponent({ name: 'Hello' }).$el.textContent).toBe( From 1f31a5122d6d41fdd48dd81189a5cebe01195803 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Wed, 15 Apr 2020 01:38:13 +0300 Subject: [PATCH 06/10] refactor: Refactor the way VueWrapper is created, to allow for wrapping nested instances. --- src/emitMixin.ts | 14 ++++---- src/error-wrapper.ts | 14 +++++--- src/mount.ts | 9 +++-- src/vue-wrapper.ts | 58 ++++++++++++++++----------------- tests/findAllComponents.spec.ts | 6 ++-- tests/findComponent.spec.ts | 27 ++++++++------- tests/setProps.spec.ts | 33 ++++++++++++++++--- tests/vm.spec.ts | 1 + 8 files changed, 97 insertions(+), 65 deletions(-) diff --git a/src/emitMixin.ts b/src/emitMixin.ts index 3688a0e6e..f3749474e 100644 --- a/src/emitMixin.ts +++ b/src/emitMixin.ts @@ -1,10 +1,11 @@ -import { getCurrentInstance } from 'vue' - -export const createEmitMixin = () => { - const events: Record = {} +import { getCurrentInstance, App } from 'vue' +export const attachEventListener = (vm: App) => { const emitMixin = { beforeCreate() { + let events: Record = {} + this.__emitted = events + getCurrentInstance().emit = (event: string, ...args: unknown[]) => { events[event] ? (events[event] = [...events[event], [...args]]) @@ -15,8 +16,5 @@ export const createEmitMixin = () => { } } - return { - events, - emitMixin - } + vm.mixin(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 d0394f023..e4e6d9f14 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -14,7 +14,7 @@ import { } from 'vue' import { createWrapper, VueWrapper } from './vue-wrapper' -import { createEmitMixin } from './emitMixin' +import { attachEventListener } from './emitMixin' import { createDataMixin } from './dataMixin' import { MOUNT_ELEMENT_ID } from './constants' import { stubComponents } from './stubs' @@ -145,8 +145,7 @@ export function mount( } // add tracking for emitted events - const { emitMixin, events } = createEmitMixin() - vm.mixin(emitMixin) + attachEventListener(vm) // stubs if (options?.global?.stubs) { @@ -157,6 +156,6 @@ export function mount( // mount the app! const app = vm.mount(el) - - return createWrapper(app, events, setProps) + const App = app.$refs['VTU_COMPONENT'] as ComponentPublicInstance + return createWrapper(App, setProps) } diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index 404dc67a3..f14c4ce1f 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -8,43 +8,36 @@ import { 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 + // TODO Remove cast after Vue releases the fix + this.rootVM = (vm.$root as any) as ComponentPublicInstance + 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 { @@ -63,8 +56,10 @@ export class VueWrapper return true } - emitted() { - return this.__emitted + emitted(): Record { + // TODO Should we define this? + // @ts-ignore + return this.vm.__emitted } html() { @@ -94,18 +89,19 @@ export class VueWrapper return result } - findComponent(selector: FindComponentSelector): ComponentPublicInstance { + findComponent(selector: FindComponentSelector): VueWrapper | ErrorWrapper { if (typeof selector === 'object' && 'ref' in selector) { - return this.componentVM.$refs[selector.ref] as ComponentPublicInstance + return createWrapper( + this.vm.$refs[selector.ref] as ComponentPublicInstance + ) } - const result = find(this.componentVM.$.subTree, selector) - return result.length ? result[0] : undefined + const result = find(this.vm.$.subTree, selector) + if (!result.length) return new ErrorWrapper({ selector }) + return createWrapper(result[0]) } - findAllComponents( - selector: FindAllComponentsSelector - ): ComponentPublicInstance[] { - return find(this.componentVM.$.subTree, selector) + findAllComponents(selector: FindAllComponentsSelector): VueWrapper[] { + return find(this.vm.$.subTree, selector).map((c) => createWrapper(c)) } findAll(selector: string): DOMWrapper[] { @@ -113,7 +109,12 @@ export class VueWrapper 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 + // TODO: Remove ignore after Vue releases fix + // @ts-ignore + if (this.vm.$parent !== this.rootVM) + throw Error('You can only use setProps on your mounted component') this.__setProps(props) return nextTick() } @@ -126,8 +127,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/findAllComponents.spec.ts b/tests/findAllComponents.spec.ts index a2a362e7c..6abfb3f9d 100644 --- a/tests/findAllComponents.spec.ts +++ b/tests/findAllComponents.spec.ts @@ -19,11 +19,9 @@ describe('findAllComponents', () => { const wrapper = mount(compA) // find by DOM selector expect(wrapper.findAllComponents('.C')).toHaveLength(2) - expect( - wrapper.findAllComponents({ name: 'Hello' })[0].$el.textContent - ).toBe('Hello world') - expect(wrapper.findAllComponents(Hello)[0].$el.textContent).toBe( + 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 index e2d1e854c..accd21906 100644 --- a/tests/findComponent.spec.ts +++ b/tests/findComponent.spec.ts @@ -37,7 +37,7 @@ const compA = { describe('findComponent', () => { it('does not find plain dom elements', () => { const wrapper = mount(compA) - expect(wrapper.findComponent('.domElement')).toBeFalsy() + expect(wrapper.findComponent('.domElement').exists()).toBeFalsy() }) it('finds component by ref', () => { @@ -49,34 +49,39 @@ describe('findComponent', () => { it('finds component by dom selector', () => { const wrapper = mount(compA) // find by DOM selector - expect(wrapper.findComponent('.C').$options.name).toEqual('ComponentC') + 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').$options.name).toEqual('ComponentC') + 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').$options.name).toEqual( + 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' }).$el.textContent).toBe( - 'Hello world' - ) - expect(wrapper.findComponent({ name: 'ComponentB' })).toBeTruthy() - expect(wrapper.findComponent({ name: 'component-c' })).toBeTruthy() + 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).$el.textContent).toBe('Hello world') - expect(wrapper.findComponent(compC).$el.textContent).toBe('C') + 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' From bf9cde89d436268166010d102aabb10871b35be7 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Thu, 16 Apr 2020 00:03:50 +0300 Subject: [PATCH 07/10] refactor: cleanup ts ignore --- src/vue-wrapper.ts | 8 +++----- yarn.lock | 25 +++++++++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index f14c4ce1f..e1e3612af 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -20,8 +20,7 @@ export class VueWrapper vm: ComponentPublicInstance, setProps?: (props: Record) => void ) { - // TODO Remove cast after Vue releases the fix - this.rootVM = (vm.$root as any) as ComponentPublicInstance + this.rootVM = vm.$root this.componentVM = vm as T this.__setProps = setProps } @@ -111,10 +110,9 @@ export class VueWrapper setProps(props: Record): Promise { // if this VM's parent is not the root, error out - // TODO: Remove ignore after Vue releases fix - // @ts-ignore - if (this.vm.$parent !== this.rootVM) + if (this.vm.$parent !== this.rootVM) { throw Error('You can only use setProps on your mounted component') + } this.__setProps(props) return nextTick() } diff --git a/yarn.lock b/yarn.lock index 43810c1c3..8845e35b1 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== @@ -1951,9 +1960,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== dashdash@^1.12.0: version "1.14.1" From 07a203b6e8bb894213c0be4ef0355f8a29f8b2fb Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Thu, 16 Apr 2020 21:59:51 +0300 Subject: [PATCH 08/10] chore: code review and type improvements --- src/constants.ts | 2 ++ src/emitMixin.ts | 8 +++----- src/mount.ts | 16 ++++++++++------ src/vue-wrapper.ts | 8 +++----- tests/findAllComponents.spec.ts | 13 +++++++------ tests/findComponent.spec.ts | 18 ++++++++++-------- 6 files changed, 35 insertions(+), 30 deletions(-) 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 f3749474e..6da5677c8 100644 --- a/src/emitMixin.ts +++ b/src/emitMixin.ts @@ -1,7 +1,7 @@ -import { getCurrentInstance, App } from 'vue' +import { getCurrentInstance } from 'vue' -export const attachEventListener = (vm: App) => { - const emitMixin = { +export const attachEmitListener = () => { + return { beforeCreate() { let events: Record = {} this.__emitted = events @@ -15,6 +15,4 @@ export const attachEventListener = (vm: App) => { } } } - - vm.mixin(emitMixin) } diff --git a/src/mount.ts b/src/mount.ts index e4e6d9f14..759e5d4fa 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -14,9 +14,13 @@ import { } from 'vue' import { createWrapper, VueWrapper } from './vue-wrapper' -import { attachEventListener } 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 } @@ -82,11 +86,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) } @@ -145,7 +149,7 @@ export function mount( } // add tracking for emitted events - attachEventListener(vm) + vm.mixin(attachEmitListener()) // stubs if (options?.global?.stubs) { @@ -156,6 +160,6 @@ export function mount( // mount the app! const app = vm.mount(el) - const App = app.$refs['VTU_COMPONENT'] as ComponentPublicInstance + const App = app.$refs[MOUNT_COMPONENT_REF] as T return createWrapper(App, setProps) } diff --git a/src/vue-wrapper.ts b/src/vue-wrapper.ts index e1e3612af..00b102167 100644 --- a/src/vue-wrapper.ts +++ b/src/vue-wrapper.ts @@ -88,18 +88,16 @@ export class VueWrapper return result } - findComponent(selector: FindComponentSelector): VueWrapper | ErrorWrapper { + findComponent(selector: FindComponentSelector): VueWrapper | ErrorWrapper { if (typeof selector === 'object' && 'ref' in selector) { - return createWrapper( - this.vm.$refs[selector.ref] as ComponentPublicInstance - ) + 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[] { + findAllComponents(selector: FindAllComponentsSelector): VueWrapper[] { return find(this.vm.$.subTree, selector).map((c) => createWrapper(c)) } diff --git a/tests/findAllComponents.spec.ts b/tests/findAllComponents.spec.ts index 6abfb3f9d..b13e65460 100644 --- a/tests/findAllComponents.spec.ts +++ b/tests/findAllComponents.spec.ts @@ -1,18 +1,19 @@ import { mount } from '../src' import Hello from './components/Hello.vue' +import { defineComponent } from 'vue' -const compC = { +const compC = defineComponent({ name: 'ComponentC', template: '
C
' -} -const compB = { +}) +const compB = defineComponent({ template: '
TextBeforeTextAfter
', components: { compC } -} -const compA = { +}) +const compA = defineComponent({ template: '
', components: { compB, Hello } -} +}) describe('findAllComponents', () => { it('finds all deeply nested vue components', () => { diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts index accd21906..499ea1abf 100644 --- a/tests/findComponent.spec.ts +++ b/tests/findComponent.spec.ts @@ -1,18 +1,19 @@ +import { defineComponent } from 'vue' import { mount } from '../src' import Hello from './components/Hello.vue' -const compC = { +const compC = defineComponent({ name: 'ComponentC', template: '
C
' -} +}) -const compD = { +const compD = defineComponent({ name: 'ComponentD', template: '', components: { compC } -} +}) -const compB = { +const compB = defineComponent({ name: 'component-b', template: `
@@ -23,8 +24,9 @@ const compB = {
`, components: { compC, compD } -} -const compA = { +}) + +const compA = defineComponent({ template: `
@@ -32,7 +34,7 @@ const compA = {
`, components: { compB, Hello } -} +}) describe('findComponent', () => { it('does not find plain dom elements', () => { From 7052f2b919e726dd200a21272ff18732e70b9b54 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Sat, 18 Apr 2020 16:56:40 +0300 Subject: [PATCH 09/10] chore: resolve merge --- src/mount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mount.ts b/src/mount.ts index 68d63e4a0..a125b86d4 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -164,6 +164,6 @@ export function mount( // mount the app! const app = vm.mount(el) - const App = app.$refs[MOUNT_COMPONENT_REF] as T + const App = app.$refs[MOUNT_COMPONENT_REF] as ComponentPublicInstance return createWrapper(App, setProps) } From fd242b86b4561367d524f2df0dfa2a4bbc94ac34 Mon Sep 17 00:00:00 2001 From: dobromir-hristov Date: Sat, 18 Apr 2020 16:56:50 +0300 Subject: [PATCH 10/10] chore: add docs --- docs/API.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) 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.