Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<template>
<div class="foo">
Foo
</div>
</template>
<script>
export default { name: 'Foo' }
</script>
```

```vue
<template>
<div>
<span>Span</span>
<Foo data-test="foo" ref="foo"/>
</div>
</template>
```

```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
<template>
<div>
<FooComponent
v-for="number in [1, 2, 3]"
:key="number"
data-test="number"
>
{{ number }}
</FooComponent>
</div>
</template>
```

```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.
Expand Down
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 @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
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)
)
}
Loading