Skip to content
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

feat: Hide props with default values within snapshot #1174

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
90 changes: 83 additions & 7 deletions src/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
} from 'vue'
import { hyphenate } from './utils/vueShared'
import { matchName } from './utils/matchName'
import { isComponent, isFunctionalComponent } from './utils'
import {
deepCompare,
hasOwnProperty,
isComponent,
isFunctionalComponent
} from './utils'
import { ComponentInternalInstance } from '@vue/runtime-core'
import { unwrapLegacyVueExtendComponent } from './utils/vueCompatSupport'
import { Stub, Stubs } from './types'
Expand Down Expand Up @@ -76,20 +81,91 @@ export const createStub = ({
const anonName = 'anonymous-stub'
const tag = name ? `${hyphenate(name)}-stub` : anonName

// Object with default values for component props
const defaultProps = (() => {
// Array-style prop declaration
if (!propsDeclaration || Array.isArray(propsDeclaration)) return {}

return Object.entries(propsDeclaration).reduce(
(defaultProps, [propName, propDeclaration]) => {
let defaultValue = undefined

if (propDeclaration) {
// Specific default value set
// myProp: { type: String, default: 'default-value' }
if (
typeof propDeclaration === 'object' &&
hasOwnProperty(propDeclaration, 'default')
) {
defaultValue = propDeclaration.default

// Default value factory?
// myProp: { type: Array, default: () => ['one'] }
if (typeof defaultValue === 'function') {
defaultValue = defaultValue()
}
} else {
const propType = (() => {
if (
typeof propDeclaration === 'function' ||
Array.isArray(propDeclaration)
)
return propDeclaration
return typeof propDeclaration === 'object' &&
hasOwnProperty(propDeclaration, 'type')
? propDeclaration.type
: null
})()

// Boolean prop declaration
// myProp: Boolean
// or
// myProp: [Boolean, String]
if (
propType === Boolean ||
(Array.isArray(propType) && propType.includes(Boolean))
) {
defaultValue = false
}
}
}

if (defaultValue !== undefined) {
defaultProps[propName] = defaultValue
}
return defaultProps
},
{} as Record<string, any>
)
})()

const render = (ctx: ComponentPublicInstance) => {
// https://github.com/vuejs/vue-test-utils-next/issues/1076
// Passing a symbol as a static prop is not legal, since Vue will try to do
// something like `el.setAttribute('val', Symbol())` which is not valid and
// causes an error.
// Only a problem when shallow mounting. For this reason we iterate of the
// props that will be passed and stringify any that are symbols.
const propsWithoutSymbols = stringifySymbols(ctx.$props)

return h(
tag,
propsWithoutSymbols,
renderStubDefaultSlot ? ctx.$slots : undefined
const propsWithoutSymbols: Record<string, unknown> = stringifySymbols(
ctx.$props
)

// Filter default value of props
const props = Object.keys(propsWithoutSymbols)
.sort()
.reduce((props, propName) => {
const propValue = propsWithoutSymbols[propName]

if (
propValue !== undefined &&
!deepCompare(propValue, defaultProps[propName])
) {
props[propName] = propValue
}
return props
}, {} as Record<string, unknown>)

return h(tag, props, renderStubDefaultSlot ? ctx.$slots : undefined)
}

return defineComponent({
Expand Down
38 changes: 38 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,44 @@ export const mergeDeep = (
return target
}

/**
* Deep compare two objects
* Does not work with circular objects and only compares method names
*/
export const deepCompare = (a: unknown, b: unknown): boolean => {
if (a === b) return true
if (!a || !b) return false
if (Array.isArray(a) !== Array.isArray(b)) return false
// Primitive objects! -> Simple compare with: ===
if (!isObject(a) || !isObject(b)) return a === b

if (Object.keys(a).length !== Object.keys(b).length) return false

for (const p of Object.keys(a)) {
if (!hasOwnProperty(b, p)) return false

if (typeof a[p] !== typeof b[p]) return false

switch (typeof a[p]) {
case 'object':
if (!deepCompare(a[p], b[p])) return false
break
case 'function':
type callable = () => void
if ((a[p] as callable).toString() !== (b[p] as callable).toString()) {
return false
}
break
default:
if (a[p] !== b[p]) {
return false
}
}
}

return true
}

export function isClassComponent(component: unknown) {
return typeof component === 'function' && '__vccOpts' in component
}
Expand Down
20 changes: 20 additions & 0 deletions tests/components/ScriptSetupDefineProps.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
withDefaults(
defineProps<{
withDefaultString?: string
withDefaultBool?: boolean
withDefaultArray?: string[]
withDefaultObject?: Record<string, string>
}>(),
{
withDefaultString: 'default-value',
withDefaultBool: false,
withDefaultArray: () => ['one', 'two'],
withDefaultObject: () => ({ obj: 'default' })
}
)
</script>

<template>
<div />
</template>
13 changes: 13 additions & 0 deletions tests/components/WithProps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ export default defineComponent({
msg: {
type: String,
required: false
},
withDefaultString: {
type: String,
default: 'default-value'
},
withDefaultBool: Boolean,
withDefaultArray: {
type: Array,
default: () => ['default-value']
},
withDefaultObject: {
type: Object,
default: () => ({ obj: 'default' })
}
}
})
Expand Down
6 changes: 5 additions & 1 deletion tests/features/teleport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ describe('teleport', () => {
const withProps = wrapper.getComponent(WithProps)

expect(withProps.props()).toEqual({
msg: 'hi there'
msg: 'hi there',
withDefaultString: 'default-value',
withDefaultBool: false,
withDefaultArray: ['default-value'],
withDefaultObject: { obj: 'default' }
})
})

Expand Down
175 changes: 175 additions & 0 deletions tests/mountingOptions/global.stubs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Hello from '../components/Hello.vue'
import ComponentWithoutName from '../components/ComponentWithoutName.vue'
import ComponentWithSlots from '../components/ComponentWithSlots.vue'
import ScriptSetupWithChildren from '../components/ScriptSetupWithChildren.vue'
import ScriptSetupDefineProps from '../components/ScriptSetupDefineProps.vue'

describe('mounting options: stubs', () => {
let configStubsSave = config.global.stubs
Expand Down Expand Up @@ -726,6 +727,180 @@ describe('mounting options: stubs', () => {
})
})

describe('stub props', () => {
const PropsComponent = defineComponent({
name: 'PropsComponent',
props: {
boolShort: Boolean,
boolAndStringShort: [String, Boolean],
boolWithoutDefault: {
type: Boolean
},
boolWithDefault: {
type: Boolean,
default: true
},
string: {
type: String,
default: 'default-value'
},
number: {
type: Number,
default: 47
},
array: {
type: Array,
default: () => ['one', 'two']
},
obj: {
type: Object,
default: () => ({ obj1: 7 })
},
nestedObj: {
type: Object,
default: () => ({ nested: { obj1: 1 } })
}
},
template: '<div/>'
})

const ParentPropsComponent = defineComponent({
props: {
childProps: {
type: Object,
default: undefined
}
},
setup(props) {
return () => h(PropsComponent, props.childProps)
}
})

it('stubs with default props', () => {
const wrapper = mount(ParentPropsComponent, {
global: {
stubs: {
PropsComponent: true
}
}
})

expect(wrapper.html()).toBe(
'<props-component-stub></props-component-stub>'
)
})

it('stubs with given default props', () => {
const wrapper = mount(ParentPropsComponent, {
props: {
childProps: {
boolShort: false,
boolAndStringShort: false,
boolWithoutDefault: false,
boolWithDefault: true,
string: 'default-value',
number: 47,
array: ['one', 'two'],
obj: { obj1: 7 },
nestedObj: { nested: { obj1: 1 } }
}
},
global: {
stubs: {
PropsComponent: true
}
}
})

expect(wrapper.html()).toBe(
'<props-component-stub></props-component-stub>'
)
})

it('stubs with given props', () => {
const wrapper = mount(ParentPropsComponent, {
props: {
childProps: {
boolShort: true,
boolAndStringShort: 'test',
boolWithoutDefault: true,
boolWithDefault: false,
string: 'test',
number: 5,
array: ['three', 'four'],
obj: { obj1: 5 },
nestedObj: { nested: { obj1: 2 } }
}
},
global: {
stubs: {
PropsComponent: true
}
}
})

expect(wrapper.html()).toBe(
'<props-component-stub ' +
'array="three,four" ' +
'boolandstringshort="test" ' +
'boolshort="true" ' +
'boolwithdefault="false" ' +
'boolwithoutdefault="true" ' +
'nestedobj="[object Object]" ' +
'number="5" ' +
'obj="[object Object]" ' +
'string="test"' +
'></props-component-stub>'
)
})

it('stubs with array style props', () => {
const ChildComponent = defineComponent({
name: 'ChildComponent',
props: ['var1', 'var2', 'var3'],
template: '<div/>'
})

const ParentComponent = defineComponent({
render: () =>
h(ChildComponent, {
var1: 'test'
})
})

const wrapper = mount(ParentComponent, {
global: {
stubs: {
ChildComponent: true
}
}
})

expect(wrapper.html()).toBe(
'<child-component-stub var1="test"></child-component-stub>'
)
})

it('stubs with script setup define props', () => {
const wrapper = mount(
defineComponent({
components: { ScriptSetupDefineProps },
render: () => h(ScriptSetupDefineProps)
}),
{
global: {
stubs: {
ScriptSetupDefineProps: true
}
}
}
)
expect(wrapper.html()).toBe(
'<script-setup-define-props-stub></script-setup-define-props-stub>'
)
})
})

it('renders stub for anonymous component when using shallow mount', () => {
const AnonymousComponent = defineComponent({
template: `<div class="original"><slot></slot></div>`
Expand Down