Skip to content

Find by component #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 18, 2020
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const MOUNT_ELEMENT_ID = 'app'
export const MOUNT_COMPONENT_REF = 'VTU_COMPONENT'
export const MOUNT_PARENT_NAME = 'VTU_ROOT'
14 changes: 5 additions & 9 deletions src/emitMixin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { getCurrentInstance } from 'vue'

export const createEmitMixin = () => {
const events: Record<string, unknown[]> = {}

const emitMixin = {
export const attachEmitListener = () => {
return {
beforeCreate() {
let events: Record<string, unknown[]> = {}
this.__emitted = events

getCurrentInstance().emit = (event: string, ...args: unknown[]) => {
events[event]
? (events[event] = [...events[event], [...args]])
Expand All @@ -14,9 +15,4 @@ export const createEmitMixin = () => {
}
}
}

return {
events,
emitMixin
}
}
14 changes: 10 additions & 4 deletions src/error-wrapper.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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')
}
Expand All @@ -34,8 +40,8 @@ export class ErrorWrapper {
throw this.wrapperError('findAll')
}

setChecked() {
throw this.wrapperError('setChecked')
setProps() {
throw this.wrapperError('setProps')
}

setValue() {
Expand Down
19 changes: 11 additions & 8 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -82,11 +86,11 @@ export function mount<T extends ComponentPublicInstance>(

// 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)
}
Expand Down Expand Up @@ -145,8 +149,7 @@ export function mount<T extends ComponentPublicInstance>(
}

// add tracking for emitted events
const { emitMixin, events } = createEmitMixin()
vm.mixin(emitMixin)
vm.mixin(attachEmitListener())

// stubs
if (options?.global?.stubs) {
Expand All @@ -157,6 +160,6 @@ export function mount<T extends ComponentPublicInstance>(

// mount the app!
const app = vm.mount(el)

return createWrapper<T>(app, events, setProps)
const App = app.$refs[MOUNT_COMPONENT_REF] as T
return createWrapper<T>(App, setProps)
}
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ export interface WrapperAPI {
text: () => string
trigger: (eventString: string) => Promise<(fn?: () => void) => Promise<void>>
}

interface RefSelector {
ref: string
}

interface NameSelector {
name: string
}

export type FindComponentSelector = RefSelector | NameSelector | string
export type FindAllComponentsSelector = NameSelector | string
61 changes: 61 additions & 0 deletions src/utils/find.ts
Original file line number Diff line number Diff line change
@@ -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
)
}
14 changes: 14 additions & 0 deletions src/utils/matchName.ts
Original file line number Diff line number Diff line change
@@ -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)
)
}
63 changes: 39 additions & 24 deletions src/vue-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends ComponentPublicInstance>
implements WrapperAPI {
private componentVM: T
private __emitted: Record<string, unknown[]> = {}
private __vm: ComponentPublicInstance
private rootVM: ComponentPublicInstance
private __setProps: (props: Record<string, any>) => void

constructor(
vm: ComponentPublicInstance,
events: Record<string, unknown[]>,
setProps: (props: Record<string, any>) => void
setProps?: (props: Record<string, any>) => 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 {
Expand All @@ -58,12 +55,14 @@ export class VueWrapper<T extends ComponentPublicInstance>
return true
}

emitted() {
return this.__emitted
emitted(): Record<string, unknown[]> {
// TODO Should we define this?
// @ts-ignore
return this.vm.__emitted
}

html() {
return this.appRootNode.innerHTML
return this.parentElement.innerHTML
}

text() {
Expand All @@ -89,12 +88,29 @@ export class VueWrapper<T extends ComponentPublicInstance>
return result
}

findComponent(selector: FindComponentSelector): VueWrapper<T> | 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<T>[] {
return find(this.vm.$.subTree, selector).map((c) => createWrapper(c))
}

findAll<T extends Element>(selector: string): DOMWrapper<T>[] {
const results = this.appRootNode.querySelectorAll<T>(selector)
const results = this.parentElement.querySelectorAll<T>(selector)
return Array.from(results).map((x) => new DOMWrapper(x))
}

setProps(props: Record<string, any>) {
setProps(props: Record<string, any>): Promise<void> {
// 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()
}
Expand All @@ -107,8 +123,7 @@ export class VueWrapper<T extends ComponentPublicInstance>

export function createWrapper<T extends ComponentPublicInstance>(
vm: ComponentPublicInstance,
events: Record<string, unknown[]>,
setProps: (props: Record<string, any>) => void
setProps?: (props: Record<string, any>) => void
): VueWrapper<T> {
return new VueWrapper<T>(vm, events, setProps)
return new VueWrapper<T>(vm, setProps)
}
1 change: 1 addition & 0 deletions tests/find.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
28 changes: 28 additions & 0 deletions tests/findAllComponents.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { mount } from '../src'
import Hello from './components/Hello.vue'
import { defineComponent } from 'vue'

const compC = defineComponent({
name: 'ComponentC',
template: '<div class="C">C</div>'
})
const compB = defineComponent({
template: '<div class="B">TextBefore<comp-c/>TextAfter<comp-c/></div>',
components: { compC }
})
const compA = defineComponent({
template: '<div class="A"><comp-b ref="b"/><hello ref="b"/></div>',
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')
})
})
Loading