diff --git a/examples/app-vitest-full/components/OptionsApiEmits.vue b/examples/app-vitest-full/components/OptionsApiEmits.vue new file mode 100644 index 000000000..4c8ef6ece --- /dev/null +++ b/examples/app-vitest-full/components/OptionsApiEmits.vue @@ -0,0 +1,30 @@ + + + diff --git a/examples/app-vitest-full/components/OptionsApiWatch.vue b/examples/app-vitest-full/components/OptionsApiWatch.vue new file mode 100644 index 000000000..37f4e23c9 --- /dev/null +++ b/examples/app-vitest-full/components/OptionsApiWatch.vue @@ -0,0 +1,46 @@ + + + diff --git a/examples/app-vitest-full/components/ScriptSetupEmits.vue b/examples/app-vitest-full/components/ScriptSetupEmits.vue new file mode 100644 index 000000000..d9c9012eb --- /dev/null +++ b/examples/app-vitest-full/components/ScriptSetupEmits.vue @@ -0,0 +1,35 @@ + + + diff --git a/examples/app-vitest-full/components/ScriptSetupWatch.vue b/examples/app-vitest-full/components/ScriptSetupWatch.vue new file mode 100644 index 000000000..19199e576 --- /dev/null +++ b/examples/app-vitest-full/components/ScriptSetupWatch.vue @@ -0,0 +1,85 @@ + + + diff --git a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts index 2c206efaa..fa894ef3f 100644 --- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts @@ -15,8 +15,12 @@ import ExportDefaultComponent from '~/components/ExportDefaultComponent.vue' import ExportDefineComponent from '~/components/ExportDefineComponent.vue' import ExportDefaultWithRenderComponent from '~/components/ExportDefaultWithRenderComponent.vue' import ExportDefaultReturnsRenderComponent from '~/components/ExportDefaultReturnsRenderComponent.vue' +import ScriptSetupEmits from '~/components/ScriptSetupEmits.vue' +import ScriptSetupWatch from '~/components/ScriptSetupWatch.vue' import OptionsApiPage from '~/pages/other/options-api.vue' import OptionsApiComputed from '~/components/OptionsApiComputed.vue' +import OptionsApiEmits from '~/components/OptionsApiEmits.vue' +import OptionsApiWatch from '~/components/OptionsApiWatch.vue' import ComponentWithAttrs from '~/components/ComponentWithAttrs.vue' import ComponentWithReservedProp from '~/components/ComponentWithReservedProp.vue' import ComponentWithReservedState from '~/components/ComponentWithReservedState.vue' @@ -227,6 +231,42 @@ describe('mountSuspended', () => { expect(component.find('[foo="bar"]').exists()).toBe(true) }) + it('should capture emits from script setup and early hooks', async () => { + const component = await mountSuspended(ScriptSetupEmits) + await expect.poll(() => component.emitted()).toEqual({ + 'event-from-setup': [[1], [2]], + 'event-from-before-mount': [[1], [2]], + 'event-from-mounted': [[1], [2]], + }) + }) + + it('should handle data set from immediate watches', async () => { + const component = await mountSuspended(ScriptSetupWatch) + await expect.poll( + () => + JSON.parse(component.find('[data-testid="set-by-watches"]').text()), + ).toEqual({ + dataFromWatchEffectOnComputedFromReactiveObject: 'data-from-reactive-object', + dataFromWatchEffectOnReactiveObject: 'data-from-reactive-object', + dataFromWatchEffectOnReactiveString: 'data-from-reactive-string', + dataFromWatchOnComputedFromReactiveObject: 'data-from-reactive-object', + dataFromWatchOnReactiveObject: 'data-from-reactive-object', + dataFromWatchOnReactiveString: 'data-from-reactive-string', + }) + }) + + it('should handle events emitted from immediate watches', async () => { + const component = await mountSuspended(ScriptSetupWatch) + await expect.poll(() => component.emitted()).toEqual({ + 'event-from-watch-effect-on-computed-from-reactive-object': [[1]], + 'event-from-watch-effect-on-reactive-object': [[1]], + 'event-from-watch-effect-on-reactive-string': [[1]], + 'event-from-watch-on-computed-from-reactive-object': [[1]], + 'event-from-watch-on-reactive-object': [[1]], + 'event-from-watch-on-reactive-string': [[1]], + }) + }) + describe('Options API', () => { beforeEach(() => { vi.spyOn(console, 'error').mockImplementation((message) => { @@ -270,6 +310,37 @@ describe('mountSuspended', () => { expect(component.find('[data-testid="object-with-get-and-set"]').text()).toBe('object-with-get-and-set') expect(console.error).not.toHaveBeenCalled() }) + + it('should capture emits from setup and early hooks', async () => { + const component = await mountSuspended(OptionsApiEmits) + await expect.poll(() => component.emitted()).toEqual({ + 'event-from-setup': [[1], [2]], + 'event-from-before-mount': [[1], [2]], + 'event-from-mounted': [[1], [2]], + }) + expect(console.error).not.toHaveBeenCalled() + }) + + it('should handle data set from immediate watches', async () => { + const component = await mountSuspended(OptionsApiWatch) + await expect.poll( + () => + JSON.parse(component.find('[data-testid="set-by-watches"]').text()), + ).toEqual({ + dataFromInternalDataObject: 'data-from-internal-data-object', + dataMappedFromExternalReactiveStore: 'data-from-external-reactive-store', + }) + expect(console.error).not.toHaveBeenCalled() + }) + + it('should handle events emitted from immediate watches', async () => { + const component = await mountSuspended(OptionsApiWatch) + await expect.poll(() => component.emitted()).toEqual({ + 'event-from-internal-data-object': [[1]], + 'event-mapped-from-external-reactive-store': [[1]], + }) + expect(console.error).not.toHaveBeenCalled() + }) }) }) diff --git a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts index e0dd12224..fd94dfa83 100644 --- a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts @@ -16,7 +16,7 @@ import ExportDefaultWithRenderComponent from '~/components/ExportDefaultWithRend import ExportDefaultReturnsRenderComponent from '~/components/ExportDefaultReturnsRenderComponent.vue' import OptionsApiPage from '~/pages/other/options-api.vue' -import { BoundAttrs } from '#components' +import { BoundAttrs, OptionsApiEmits, OptionsApiWatch, ScriptSetupEmits, ScriptSetupWatch } from '#components' const formats = { ExportDefaultComponent, @@ -134,6 +134,42 @@ describe('renderSuspended', () => { `) }) + it('should capture emits from script setup and early hooks', async () => { + const { emitted } = await renderSuspended(ScriptSetupEmits) + await expect.poll(() => emitted()).toEqual({ + 'event-from-setup': [[1], [2]], + 'event-from-before-mount': [[1], [2]], + 'event-from-mounted': [[1], [2]], + }) + }) + + it('should handle data set from immediate watches', async () => { + const { getByTestId } = await renderSuspended(ScriptSetupWatch) + await expect.poll( + () => + JSON.parse(getByTestId('set-by-watches').textContent || '{}'), + ).toEqual({ + dataFromWatchEffectOnComputedFromReactiveObject: 'data-from-reactive-object', + dataFromWatchEffectOnReactiveObject: 'data-from-reactive-object', + dataFromWatchEffectOnReactiveString: 'data-from-reactive-string', + dataFromWatchOnComputedFromReactiveObject: 'data-from-reactive-object', + dataFromWatchOnReactiveObject: 'data-from-reactive-object', + dataFromWatchOnReactiveString: 'data-from-reactive-string', + }) + }) + + it('should handle events emitted from immediate watches', async () => { + const { emitted } = await renderSuspended(ScriptSetupWatch) + await expect.poll(() => emitted()).toEqual({ + 'event-from-watch-effect-on-computed-from-reactive-object': [[1]], + 'event-from-watch-effect-on-reactive-object': [[1]], + 'event-from-watch-effect-on-reactive-string': [[1]], + 'event-from-watch-on-computed-from-reactive-object': [[1]], + 'event-from-watch-on-reactive-object': [[1]], + 'event-from-watch-on-reactive-string': [[1]], + }) + }) + describe('Options API', () => { beforeEach(() => { vi.spyOn(console, 'error').mockImplementation((message) => { @@ -169,6 +205,37 @@ describe('renderSuspended', () => { await fireEvent.click(getByTestId('test-button')) expect(console.error).not.toHaveBeenCalled() }) + + it('should capture emits from setup and early hooks', async () => { + const { emitted } = await renderSuspended(OptionsApiEmits) + await expect.poll(() => emitted()).toEqual({ + 'event-from-setup': [[1], [2]], + 'event-from-before-mount': [[1], [2]], + 'event-from-mounted': [[1], [2]], + }) + expect(console.error).not.toHaveBeenCalled() + }) + + it('should handle data set from immediate watches', async () => { + const { getByTestId } = await renderSuspended(OptionsApiWatch) + await expect.poll( + () => + JSON.parse(getByTestId('set-by-watches').textContent || '{}'), + ).toEqual({ + dataFromInternalDataObject: 'data-from-internal-data-object', + dataMappedFromExternalReactiveStore: 'data-from-external-reactive-store', + }) + expect(console.error).not.toHaveBeenCalled() + }) + + it('should handle events emitted from immediate watches', async () => { + const { emitted } = await renderSuspended(OptionsApiWatch) + await expect.poll(() => emitted()).toEqual({ + 'event-from-internal-data-object': [[1]], + 'event-mapped-from-external-reactive-store': [[1]], + }) + expect(console.error).not.toHaveBeenCalled() + }) }) }) diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts index baba46d75..d6d746ba0 100644 --- a/src/runtime-utils/mount.ts +++ b/src/runtime-utils/mount.ts @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils' import type { ComponentMountingOptions } from '@vue/test-utils' import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance } from 'vue' -import type { DefineComponent, SetupContext } from 'vue' +import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue' import { defu } from 'defu' import type { RouteLocationRaw } from 'vue-router' @@ -17,6 +17,7 @@ type MountSuspendedOptions = ComponentMountingOptions & { // TODO: improve return types // eslint-disable-next-line @typescript-eslint/no-explicit-any type SetupState = Record +type Emit = ComponentInternalInstance['emit'] /** * `mountSuspended` allows you to mount any vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. For example: @@ -65,9 +66,49 @@ export async function mountSuspended( let setupState: Record const setProps = reactive>({}) + let interceptedEmit: Emit | null = null + /** + * Intercept the emit for testing purposes. + * + * @remarks + * Using this function ensures that the emit is not intercepted multiple times + * and doesn't duplicate events. + * + * @param emit - The original emit from the component's context. + * + * @returns An intercepted emit that will both emit from the component itself + * and from the top level wrapper for assertions via + * {@link import('@vue/test-utils').VueWrapper.emitted()}. + */ + function getInterceptedEmitFunction(emit: Emit): Emit { + if (emit !== interceptedEmit) { + interceptedEmit = interceptedEmit ?? ((event, ...args) => { + emit(event, ...args) + setupContext.emit(event, ...args) + }) + } + + return interceptedEmit + } + + /** + * Intercept emit for assertions in populate wrapper emitted. + */ + function interceptEmitOnCurrentInstance(): void { + const currentInstance = getCurrentInstance() + if (!currentInstance) { + return + } + + currentInstance.emit = getInterceptedEmitFunction(currentInstance.emit) + } + let passedProps: Record - const wrappedSetup = async (props: Record, setupContext: SetupContext) => { + const wrappedSetup = async (props: Record, setupContext: SetupContext): Promise => { + interceptEmitOnCurrentInstance() + passedProps = props + if (setup) { const result = await setup(props, setupContext) setupState = result && typeof result === 'object' ? result : {} @@ -107,28 +148,14 @@ export async function mountSuspended( const router = useRouter() await router.replace(route) - let interceptedEmit: ((event: string, ...args: unknown[]) => void) | null = null - // Proxy top-level setup/render context so test wrapper resolves child component const clonedComponent = { name: 'MountSuspendedComponent', ...component, render: render ? function (this: unknown, _ctx: Record, ...args: unknown[]) { - // When using defineModel, getCurrentInstance().emit is executed internally. it needs to override. - const currentInstance = getCurrentInstance() - if ( - currentInstance - // Intercept the emit only once. Otherwise the events would be duplicated for every rerender. - && currentInstance.emit !== interceptedEmit - ) { - const oldEmit = currentInstance.emit - interceptedEmit = (event, ...args) => { - oldEmit(event, ...args) - setupContext.emit(event, ...args) - } - currentInstance.emit = interceptedEmit - } + interceptEmitOnCurrentInstance() + // Set before setupState set to allow asyncData to overwrite data if (data && typeof data === 'function') { // @ts-expect-error error TS2554: Expected 1 arguments, but got 0 @@ -178,7 +205,7 @@ export async function mountSuspended( return render.call(this, renderContext, ...args) } : undefined, - setup: setup ? (props: Record) => wrappedSetup(props, setupContext) : undefined, + setup: (props: Record) => wrappedSetup(props, setupContext), } return () => h(clonedComponent, { ...props, ...setProps, ...attrs }, slots) diff --git a/src/runtime-utils/render.ts b/src/runtime-utils/render.ts index 0bef20d4a..bd524a2fb 100644 --- a/src/runtime-utils/render.ts +++ b/src/runtime-utils/render.ts @@ -1,5 +1,5 @@ import { Suspense, effectScope, h, nextTick, isReadonly, reactive, unref, defineComponent } from 'vue' -import type { DefineComponent, SetupContext } from 'vue' +import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue' import type { RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue' import { defu } from 'defu' import type { RouteLocationRaw } from 'vue-router' @@ -17,6 +17,7 @@ const WRAPPER_EL_ID = 'test-wrapper' // eslint-disable-next-line @typescript-eslint/no-explicit-any type SetupState = Record +type Emit = ComponentInternalInstance['emit'] /** * `renderSuspended` allows you to mount any vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. * @@ -66,6 +67,43 @@ export async function renderSuspended(component: T, options?: RenderOptions { + emit(event, ...args) + setupContext.emit(event, ...args) + }) + } + + return interceptedEmit + } + + /** + * Intercept emit for assertions in populate wrapper emitted. + */ + function interceptEmitOnCurrentInstance(): void { + const currentInstance = getCurrentInstance() + if (!currentInstance) { + return + } + + currentInstance.emit = getInterceptedEmitFunction(currentInstance.emit) + } + // cleanup previously mounted test wrappers for (const fn of window.__cleanup || []) { fn() @@ -73,11 +111,11 @@ export async function renderSuspended(component: T, options?: RenderOptions - const wrappedSetup = async ( - props: Record, - setupContext: SetupContext, - ) => { + const wrappedSetup = async (props: Record, setupContext: SetupContext): Promise => { + interceptEmitOnCurrentInstance() + passedProps = props + if (setup) { const result = await setup(props, setupContext) setupState = result && typeof result === 'object' ? result : {} @@ -140,6 +178,8 @@ export async function renderSuspended(component: T, options?: RenderOptions, ...args: unknown[]) { + interceptEmitOnCurrentInstance() + // Set before setupState set to allow asyncData to overwrite data if (data && typeof data === 'function') { // @ts-expect-error error TS2554: Expected 1 arguments, but got 0 @@ -189,7 +229,7 @@ export async function renderSuspended(component: T, options?: RenderOptions) => wrappedSetup(props, setupContext) : undefined, + setup: (props: Record) => wrappedSetup(props, setupContext), } return () => h(clonedComponent, { ...(props && typeof props === 'object' ? props : {}), ...attrs }, slots)