diff --git a/src/stubs.ts b/src/stubs.ts index 569269ea3..dd57ef03f 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -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' @@ -76,6 +81,64 @@ 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 + ) + })() + 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 @@ -83,13 +146,26 @@ export const createStub = ({ // 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 = 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) + + return h(tag, props, renderStubDefaultSlot ? ctx.$slots : undefined) } return defineComponent({ diff --git a/src/utils.ts b/src/utils.ts index 2748805c7..32b367b19 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 } diff --git a/tests/components/ScriptSetupDefineProps.vue b/tests/components/ScriptSetupDefineProps.vue new file mode 100644 index 000000000..d9521a7d5 --- /dev/null +++ b/tests/components/ScriptSetupDefineProps.vue @@ -0,0 +1,20 @@ + + + diff --git a/tests/components/WithProps.vue b/tests/components/WithProps.vue index 36aa30a1e..d78492f0c 100644 --- a/tests/components/WithProps.vue +++ b/tests/components/WithProps.vue @@ -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' }) } } }) diff --git a/tests/features/teleport.spec.ts b/tests/features/teleport.spec.ts index be6f09976..7ad855bb6 100644 --- a/tests/features/teleport.spec.ts +++ b/tests/features/teleport.spec.ts @@ -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' } }) }) diff --git a/tests/mountingOptions/global.stubs.spec.ts b/tests/mountingOptions/global.stubs.spec.ts index 0ad5766de..7a42dd707 100644 --- a/tests/mountingOptions/global.stubs.spec.ts +++ b/tests/mountingOptions/global.stubs.spec.ts @@ -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 @@ -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: '
' + }) + + 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( + '' + ) + }) + + 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( + '' + ) + }) + + 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( + '' + ) + }) + + it('stubs with array style props', () => { + const ChildComponent = defineComponent({ + name: 'ChildComponent', + props: ['var1', 'var2', 'var3'], + template: '
' + }) + + const ParentComponent = defineComponent({ + render: () => + h(ChildComponent, { + var1: 'test' + }) + }) + + const wrapper = mount(ParentComponent, { + global: { + stubs: { + ChildComponent: true + } + } + }) + + expect(wrapper.html()).toBe( + '' + ) + }) + + it('stubs with script setup define props', () => { + const wrapper = mount( + defineComponent({ + components: { ScriptSetupDefineProps }, + render: () => h(ScriptSetupDefineProps) + }), + { + global: { + stubs: { + ScriptSetupDefineProps: true + } + } + } + ) + expect(wrapper.html()).toBe( + '' + ) + }) + }) + it('renders stub for anonymous component when using shallow mount', () => { const AnonymousComponent = defineComponent({ template: `
` diff --git a/tests/props.spec.ts b/tests/props.spec.ts index f4788c3e1..f4f0fa39c 100644 --- a/tests/props.spec.ts +++ b/tests/props.spec.ts @@ -12,7 +12,28 @@ describe('props', () => { it('returns all props applied to a component', () => { const wrapper = mount(WithProps, { props: { msg: 'ABC' } }) - expect(wrapper.props()).toEqual({ msg: 'ABC' }) + expect(wrapper.props()).toEqual({ + msg: 'ABC', + withDefaultString: 'default-value', + withDefaultBool: false, + withDefaultArray: ['default-value'], + withDefaultObject: { obj: 'default' } + }) + }) + + it('return default value of string prop', () => { + const wrapper = mount(WithProps, { props: { msg: 'ABC' } }) + expect(wrapper.props('withDefaultString')).toBe('default-value') + }) + + it('return default value of boolean prop', () => { + const wrapper = mount(WithProps, { props: { msg: 'ABC' } }) + expect(wrapper.props('withDefaultBool')).toBe(false) + }) + + it('return default value of object prop', () => { + const wrapper = mount(WithProps, { props: { msg: 'ABC' } }) + expect(wrapper.props('withDefaultObject')).toEqual({ obj: 'default' }) }) it('returns undefined if props does not exist', () => { diff --git a/tests/utils.jest.spec.ts b/tests/utils.jest.spec.ts new file mode 100644 index 000000000..4782fe793 --- /dev/null +++ b/tests/utils.jest.spec.ts @@ -0,0 +1,70 @@ +/** + * deepCompare + */ +import { deepCompare } from '../src/utils' + +describe('deepCompare', () => { + it('should be equal', () => { + expect(deepCompare(1, 1)).toBe(true) + expect(deepCompare('1', '1')).toBe(true) + expect(deepCompare(true, true)).toBe(true) + expect(deepCompare(false, false)).toBe(true) + expect(deepCompare({}, {})).toBe(true) + expect(deepCompare([], [])).toBe(true) + expect(deepCompare({ a: 1 }, { a: 1 })).toBe(true) + expect(deepCompare({ a: '1' }, { a: '1' })).toBe(true) + expect(deepCompare({ a: '1', b: 2 }, { a: '1', b: 2 })).toBe(true) + expect(deepCompare({ a: '1', b: [] }, { a: '1', b: [] })).toBe(true) + expect(deepCompare({ a: '1', b: [1, 2] }, { a: '1', b: [1, 2] })).toBe(true) + expect(deepCompare([1], [1])).toBe(true) + expect(deepCompare([1, 1], [1, 1])).toBe(true) + expect(deepCompare([1, '1'], [1, '1'])).toBe(true) + expect(deepCompare(['1', 1], ['1', 1])).toBe(true) + expect(deepCompare([{}], [{}])).toBe(true) + expect(deepCompare([{ a: 1 }], [{ a: 1 }])).toBe(true) + expect( + deepCompare( + { + method() {} + }, + { + method() {} + } + ) + ).toBe(true) + }) + + it('should not be equal', () => { + expect(deepCompare(1, 2)).toBe(false) + expect(deepCompare(1, null)).toBe(false) + expect(deepCompare('1', '2')).toBe(false) + expect(deepCompare({}, { a: 1 })).toBe(false) + expect(deepCompare({ a: 1 }, {})).toBe(false) + expect(deepCompare({ a: 1 }, { a: 2 })).toBe(false) + expect(deepCompare({ a: 2 }, { a: 1 })).toBe(false) + expect(deepCompare({ a: 2 }, { b: 2 })).toBe(false) + expect(deepCompare({ a: { b: 1 } }, { a: { c: 1 } })).toBe(false) + expect(deepCompare([], [1])).toBe(false) + expect(deepCompare([1], [])).toBe(false) + expect(deepCompare([], ['1'])).toBe(false) + expect(deepCompare(['1'], [])).toBe(false) + expect(deepCompare(1, {})).toBe(false) + expect(deepCompare({}, 1)).toBe(false) + expect(deepCompare(1, { a: 1 })).toBe(false) + expect(deepCompare({ a: 1 }, 1)).toBe(false) + expect(deepCompare({}, [])).toBe(false) + expect(deepCompare([], {})).toBe(false) + expect(deepCompare({ a: 1 }, [1])).toBe(false) + expect(deepCompare([1], { a: 1 })).toBe(false) + expect( + deepCompare( + { + method: function x() {} + }, + { + method: function y() {} + } + ) + ).toBe(false) + }) +})