diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67319d427..49fae2edc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,9 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run lint - - run: npm run build - - run: npm test + - run: yarn install + - run: yarn lint + - run: yarn test + - run: yarn build env: CI: true diff --git a/package.json b/package.json index 4cde402dd..6575b4a1b 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,17 @@ "dist", "README.md" ], + "dependencies": { + "lodash": "^4.17.15" + }, "devDependencies": { "@babel/core": "^7.9.0", "@babel/preset-env": "^7.8.4", "@babel/types": "^7.8.3", + "@rollup/plugin-node-resolve": "^7.1.3", "@types/estree": "^0.0.42", "@types/jest": "^24.9.1", + "@types/lodash": "^4.14.149", "@vue/compiler-sfc": "^3.0.0-alpha.12", "babel-jest": "^25.2.3", "babel-preset-jest": "^25.2.1", diff --git a/rollup.config.js b/rollup.config.js index 6220b86ed..d3a65ae96 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,5 @@ import ts from 'rollup-plugin-typescript2' +import resolve from '@rollup/plugin-node-resolve' import pkg from './package.json' @@ -19,8 +20,15 @@ function createEntry(options) { const config = { input, - external: ['vue'], - plugins: [], + external: [ + 'vue', + 'lodash/mergeWith', + 'lodash/camelCase', + 'lodash/upperFirst', + 'lodash/kebabCase', + 'lodash/flow' + ], + plugins: [resolve()], output: { banner, file: 'dist/vue-test-utils.other.js', diff --git a/src/mount.ts b/src/mount.ts index 97b84104f..5739b5518 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -5,6 +5,7 @@ import { defineComponent, VNodeNormalizedChildren, ComponentOptions, + transformVNodeArgs, Plugin, Directive, Component, @@ -15,6 +16,7 @@ import { createWrapper } from './vue-wrapper' import { createEmitMixin } from './emitMixin' import { createDataMixin } from './dataMixin' import { MOUNT_ELEMENT_ID } from './constants' +import { stubComponents } from './stubs' type Slot = VNode | string | { render: Function } @@ -29,6 +31,7 @@ interface MountingOptions { plugins?: Plugin[] mixins?: ComponentOptions[] mocks?: Record + stubs?: Record provide?: Record // TODO how to type `defineComponent`? Using `any` for now. components?: Record @@ -72,6 +75,7 @@ export function mount(originalComponent: any, options?: MountingOptions) { // create the wrapper component const Parent = defineComponent({ + name: 'VTU_COMPONENT', render() { return h(component, props, slots) } @@ -133,6 +137,13 @@ export function mount(originalComponent: any, options?: MountingOptions) { const { emitMixin, events } = createEmitMixin() vm.mixin(emitMixin) + // stubs + if (options?.global?.stubs) { + stubComponents(options.global.stubs) + } else { + transformVNodeArgs() + } + // mount the app! const app = vm.mount(el) diff --git a/src/stubs.ts b/src/stubs.ts new file mode 100644 index 000000000..cac58d89e --- /dev/null +++ b/src/stubs.ts @@ -0,0 +1,80 @@ +import { transformVNodeArgs, h } from 'vue' + +import { pascalCase, kebabCase } from './utils' + +interface IStubOptions { + name?: string +} + +// TODO: figure out how to type this +type VNodeArgs = any[] + +export const createStub = (options: IStubOptions) => { + const tag = options.name ? `${options.name}-stub` : 'anonymous-stub' + const render = () => h(tag) + + return { name: tag, render } +} + +const resolveComponentStubByName = ( + componentName: string, + stubs: Record +) => { + const componentPascalName = pascalCase(componentName) + const componentKebabName = kebabCase(componentName) + + for (const [stubKey, value] of Object.entries(stubs)) { + if ( + stubKey === componentPascalName || + stubKey === componentKebabName || + stubKey === componentName + ) { + return value + } + } +} + +const isHTMLElement = (args: VNodeArgs) => + Array.isArray(args) && typeof args[0] === 'string' + +const isCommentOrFragment = (args: VNodeArgs) => typeof args[0] === 'symbol' + +const isParent = (args: VNodeArgs) => + typeof args[0] === 'object' && args[0]['name'] === 'VTU_COMPONENT' + +const isComponent = (args: VNodeArgs) => typeof args[0] === 'object' + +export function stubComponents(stubs: Record) { + transformVNodeArgs((args) => { + // args[0] can either be: + // 1. a HTML tag (div, span...) + // 2. An object of component options, such as { name: 'foo', render: [Function], props: {...} } + // Depending what it is, we do different things. + if (isHTMLElement(args) || isCommentOrFragment(args) || isParent(args)) { + return args + } + + if (isComponent(args)) { + const name = args[0]['name'] + if (!name) { + return args + } + + const stub = resolveComponentStubByName(name, stubs) + + // we return a stub by matching Vue's `h` function + // where the signature is h(Component, props) + // case 1: default stub + if (stub === true) { + return [createStub({ name }), {}] + } + + // case 2: custom implementation + if (typeof stub === 'object') { + return [stubs[name], {}] + } + } + + return args + }) +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 000000000..b4a362926 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,8 @@ +import camelCase from 'lodash/camelCase' +import upperFirst from 'lodash/upperFirst' +import kebabCase from 'lodash/kebabCase' +import flow from 'lodash/flow' + +const pascalCase = flow(camelCase, upperFirst) + +export { kebabCase, pascalCase } diff --git a/tests/mountingOptions/stubs.global.spec.ts b/tests/mountingOptions/stubs.global.spec.ts new file mode 100644 index 000000000..63b7b07ec --- /dev/null +++ b/tests/mountingOptions/stubs.global.spec.ts @@ -0,0 +1,220 @@ +import { h, ComponentOptions } from 'vue' + +import { mount } from '../../src' +import Hello from '../components/Hello.vue' + +describe('mounting options: stubs', () => { + it('stubs in a fragment', () => { + const Foo = { + name: 'Foo', + render() { + return h('p') + } + } + const Component: ComponentOptions = { + render() { + return h(() => [h('div'), h(Foo)]) + } + } + + const wrapper = mount(Component, { + global: { + stubs: { + Foo: true + } + } + }) + + expect(wrapper.html()).toBe('
') + }) + + it('prevents lifecycle hooks triggering in a stub', () => { + const onBeforeMount = jest.fn() + const beforeCreate = jest.fn() + const Foo = { + name: 'Foo', + setup() { + onBeforeMount(onBeforeMount) + return () => h('div') + }, + beforeCreate + } + const Comp = { + render() { + return h(Foo) + } + } + + const wrapper = mount(Comp, { + global: { + stubs: { + Foo: true + } + } + }) + + expect(wrapper.html()).toBe('') + expect(onBeforeMount).not.toHaveBeenCalled() + expect(beforeCreate).not.toHaveBeenCalled() + }) + + it('uses a custom stub implementation', () => { + const onBeforeMount = jest.fn() + const FooStub = { + name: 'FooStub', + setup() { + onBeforeMount(onBeforeMount) + return () => h('div', 'foo stub') + } + } + const Foo = { + name: 'Foo', + render() { + return h('div', 'real foo') + } + } + + const Comp = { + render() { + return h(Foo) + } + } + + const wrapper = mount(Comp, { + global: { + stubs: { + Foo: FooStub + } + } + }) + + expect(onBeforeMount).toHaveBeenCalled() + expect(wrapper.html()).toBe('
foo stub
') + }) + + it('uses an sfc as a custom stub', () => { + const created = jest.fn() + const HelloComp = { + name: 'Hello', + created() { + created() + }, + render() { + return h('span', 'real implementation') + } + } + + const Comp = { + render() { + return h(HelloComp) + } + } + + const wrapper = mount(Comp, { + global: { + stubs: { + Hello: Hello + } + } + }) + + expect(created).not.toHaveBeenCalled() + expect(wrapper.html()).toBe( + '
Hello world
' + ) + }) + + it('stubs using inline components', () => { + const Foo = { + name: 'Foo', + render() { + return h('p') + } + } + const Bar = { + name: 'Bar', + render() { + return h('p') + } + } + const Component: ComponentOptions = { + render() { + return h(() => [h(Foo), h(Bar)]) + } + } + + const wrapper = mount(Component, { + global: { + stubs: { + Foo: { + template: '' + }, + Bar: { + render() { + return h('div') + } + } + } + } + }) + + expect(wrapper.html()).toBe('
') + }) + + it('stubs a component with a kabeb-case name', () => { + const FooBar = { + name: 'foo-bar', + render: () => h('span', 'real foobar') + } + const Comp = { + render: () => h(FooBar) + } + const wrapper = mount(Comp, { + global: { + stubs: { + FooBar: true + } + } + }) + + expect(wrapper.html()).toBe('') + }) + + it('stubs a component with a PascalCase name', () => { + const FooBar = { + name: 'FooBar', + render: () => h('span', 'real foobar') + } + const Comp = { + render: () => h(FooBar) + } + const wrapper = mount(Comp, { + global: { + stubs: { + 'foo-bar': true + } + } + }) + + expect(wrapper.html()).toBe('') + }) + + it('stubs a component with registered with strange casing', () => { + const FooBar = { + name: 'fooBar', + render: () => h('span', 'real foobar') + } + const Comp = { + render: () => h(FooBar) + } + const wrapper = mount(Comp, { + global: { + stubs: { + fooBar: true + } + } + }) + + expect(wrapper.html()).toBe('') + }) +}) diff --git a/yarn.lock b/yarn.lock index 784402de1..8566d68e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1067,6 +1067,26 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@rollup/plugin-node-resolve@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca" + integrity sha512-RxtSL3XmdTAE2byxekYLnx+98kEUOrPHF/KRVjLH+DEIHy6kjIw7YINQzn+NXiH/NTrQLAwYs0GWB+csWygA9Q== + dependencies: + "@rollup/pluginutils" "^3.0.8" + "@types/resolve" "0.0.8" + builtin-modules "^3.1.0" + is-module "^1.0.0" + resolve "^1.14.2" + +"@rollup/pluginutils@^3.0.8": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.0.9.tgz#aa6adca2c45e5a1b950103a999e3cddfe49fd775" + integrity sha512-TLZavlfPAZYI7v33wQh4mTP6zojne14yok3DNSLcjoG/Hirxfkonn6icP5rrNWRn8nZsirJBFFpijVOJzkUHDg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" + micromatch "^4.0.2" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -1124,6 +1144,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.42.tgz#8d0c1f480339efedb3e46070e22dd63e0430dd11" integrity sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ== +"@types/estree@0.0.39": + version "0.0.39" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -1151,6 +1176,11 @@ dependencies: jest-diff "^24.3.0" +"@types/lodash@^4.14.149": + version "4.14.149" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440" + integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ== + "@types/node@*": version "13.7.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.4.tgz#76c3cb3a12909510f52e5dc04a6298cdf9504ffd" @@ -1161,6 +1191,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/resolve@0.0.8": + version "0.0.8" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194" + integrity sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ== + dependencies: + "@types/node" "*" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -1620,6 +1657,11 @@ buffer-from@1.x, buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== +builtin-modules@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484" + integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw== + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -2124,6 +2166,11 @@ estree-walker@^0.8.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.8.1.tgz#6230ce2ec9a5cb03888afcaf295f97d90aa52b79" integrity sha512-H6cJORkqvrNziu0KX2hqOMAlA2CiuAxHeGJXSIoKA/KLv229Dw806J3II6mKTm5xiDX1At1EXCfsOQPB+tMB+g== +estree-walker@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" + integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -2735,6 +2782,11 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= + is-number@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -4307,7 +4359,7 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@1.15.1, resolve@1.x, resolve@^1.3.2: +resolve@1.15.1, resolve@1.x, resolve@^1.14.2, resolve@^1.3.2: version "1.15.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8" integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==