diff --git a/examples/app-vitest-browser/components/CustomRandom.vue b/examples/app-vitest-browser/components/CustomRandom.vue new file mode 100644 index 000000000..983cb00d3 --- /dev/null +++ b/examples/app-vitest-browser/components/CustomRandom.vue @@ -0,0 +1,45 @@ + + + diff --git a/examples/app-vitest-browser/test/mount-suspended.spec.ts b/examples/app-vitest-browser/test/mount-suspended.spec.ts index cb8c77fea..c14be668c 100644 --- a/examples/app-vitest-browser/test/mount-suspended.spec.ts +++ b/examples/app-vitest-browser/test/mount-suspended.spec.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' -import { MyCounter } from '#components' +import { MyCounter, CustomRandom } from '#components' describe('Component (MyCounter)', () => { it('renders', async () => { @@ -12,7 +12,7 @@ describe('Component (MyCounter)', () => { it('can be interacted with (increment)', async () => { const component = await mountSuspended(MyCounter) - const incrementButton = component.findAll('button').filter(btn => btn.text().includes('Increment'))[0] + const incrementButton = component.findAll('button').filter(btn => btn.text().includes('Increment'))[0]! incrementButton.element.click() await nextTick() expect(component.text()).toContain('Count: 1') @@ -20,7 +20,7 @@ describe('Component (MyCounter)', () => { it('can be interacted with (decrement)', async () => { const component = await mountSuspended(MyCounter) - const decrementButton = component.findAll('button').filter(btn => btn.text().includes('Decrement'))[0] + const decrementButton = component.findAll('button').filter(btn => btn.text().includes('Decrement'))[0]! decrementButton.element.click() await nextTick() expect(component.text()).toContain('Count: -1') @@ -30,4 +30,32 @@ describe('Component (MyCounter)', () => { const component = await mountSuspended(MyCounter) expect(component.text()).toContain('"buildAssetsDir": "/_nuxt/"') }) + + it('can spy component setup state', async () => { + const wrapper = await mountSuspended(CustomRandom, { + spy: true, + }) + + vi.mocked(wrapper.setupState.getRandom).mockImplementation(() => 200) + + await wrapper.find('#random').trigger('click') + + expect(wrapper.setupState.getRandom).toHaveBeenCalled() + expect(wrapper.setupState.random).toHaveBeenCalledWith(200) + expect(wrapper.setupState.input.value).toBe(400) + }) + + it('can spy component exposed via setupState', async () => { + const wrapper = await mountSuspended(CustomRandom, { + spy: true, + }) + + vi.mocked(wrapper.setupState.getRandom2).mockImplementation(() => 200) + + await wrapper.find('#random2').trigger('click') + + expect(wrapper.setupState.getRandom2).toHaveBeenCalled() + expect(wrapper.setupState.random2).toHaveBeenCalledWith(200) + expect(wrapper.setupState.input.value).toBe(400) + }) }) diff --git a/examples/app-vitest-full/components/CustomRandom.vue b/examples/app-vitest-full/components/CustomRandom.vue new file mode 100644 index 000000000..983cb00d3 --- /dev/null +++ b/examples/app-vitest-full/components/CustomRandom.vue @@ -0,0 +1,45 @@ + + + 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 fc6e92f90..62c80a3ef 100644 --- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts @@ -30,7 +30,7 @@ import ComponentWithCssVar from '~/components/ComponentWithCssVar.vue' import ComponentWithPluginProvidedValue from '~/components/ComponentWithPluginProvidedValue.vue' import GenericStateComponent from '~/components/GenericStateComponent.vue' -import { BoundAttrs, TestTeleport } from '#components' +import { BoundAttrs, TestTeleport, CustomRandom } from '#components' import DirectiveComponent from '~/components/DirectiveComponent.vue' import CustomComponent from '~/components/CustomComponent.vue' import WrapperElement from '~/components/WrapperElement.vue' @@ -676,4 +676,32 @@ describe('watcher cleanup validation', () => { expect(watcherCallCount).toBe(1) }) + + it('can spy component setup state via setupState', async () => { + const wrapper = await mountSuspended(CustomRandom, { + spy: true, + }) + + vi.mocked(wrapper.setupState.getRandom).mockImplementation(() => 200) + + await wrapper.find('#random').trigger('click') + + expect(wrapper.setupState.getRandom).toHaveBeenCalled() + expect(wrapper.setupState.random).toHaveBeenCalledWith(200) + expect(wrapper.setupState.input.value).toBe(400) + }) + + it('can spy component exposed via setupState', async () => { + const wrapper = await mountSuspended(CustomRandom, { + spy: true, + }) + + vi.mocked(wrapper.setupState.getRandom2).mockImplementation(() => 200) + + await wrapper.find('#random2').trigger('click') + + expect(wrapper.setupState.getRandom2).toHaveBeenCalled() + expect(wrapper.setupState.random2).toHaveBeenCalledWith(200) + expect(wrapper.setupState.input.value).toBe(400) + }) }) 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 0a1bc1f66..2dbbb2179 100644 --- a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts @@ -23,7 +23,16 @@ import CompostionApi from '~/components/TestComponentWithCompostionApi.vue' import OptionsApiWithData from '~/components/TestComponentWithOptionsApiWithData.vue' import OptionsApiWithSetup from '~/components/TestComponentWithOptionsApiWithSetup.vue' -import { BoundAttrs, OptionsApiComputed, OptionsApiEmits, OptionsApiWatch, ScriptSetupEmits, ScriptSetupWatch, TestTeleport } from '#components' +import { + BoundAttrs, + CustomRandom, + OptionsApiComputed, + OptionsApiEmits, + OptionsApiWatch, + ScriptSetupEmits, + ScriptSetupWatch, + TestTeleport, +} from '#components' const formats = { ExportDefaultComponent, @@ -408,3 +417,31 @@ it('teleport should work', async () => { wrapper.unmount() expect(document.getElementById('teleport-title')).toBeFalsy() }) + +it('can spy component setup state via setupState', async () => { + const wrapper = await renderSuspended(CustomRandom, { + spy: true, + }) + + vi.mocked(wrapper.setupState.getRandom).mockImplementation(() => 200) + + await fireEvent.click(wrapper.getByRole('button', { name: 'Random' })) + + expect(wrapper.setupState.getRandom).toHaveBeenCalled() + expect(wrapper.setupState.random).toHaveBeenCalledWith(200) + expect(wrapper.setupState.input.value).toBe(400) +}) + +it('can spy component exposed via setupState', async () => { + const wrapper = await renderSuspended(CustomRandom, { + spy: true, + }) + + vi.mocked(wrapper.setupState.getRandom2).mockImplementation(() => 200) + + await fireEvent.click(wrapper.getByRole('button', { name: 'Random2' })) + + expect(wrapper.setupState.getRandom2).toHaveBeenCalled() + expect(wrapper.setupState.random2).toHaveBeenCalledWith(200) + expect(wrapper.setupState.input.value).toBe(400) +}) diff --git a/src/runtime-utils/utils/suspended.ts b/src/runtime-utils/utils/suspended.ts index d6eb3bc4f..271dfec52 100644 --- a/src/runtime-utils/utils/suspended.ts +++ b/src/runtime-utils/utils/suspended.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { Suspense, effectScope, h, nextTick, reactive, getCurrentInstance, onErrorCaptured } from 'vue' import type { App, ComponentInternalInstance, DefineComponent, SetupContext, VNode } from 'vue' import type { RouteLocationRaw } from 'vue-router' @@ -20,11 +21,30 @@ type WrapperFnOption = Fn extends (c: WrapperFnComponent, o: infer O) => type WrapperFnResult = Fn extends (c: WrapperFnComponent, o: WrapperFnOption) => infer R ? R : never export type WrapperSuspendedOptions = WrapperFnOption & { + /** + * The initial route, or false to skip the initial route change. + * @default '/' + */ route?: RouteLocationRaw | false + /** + * Enable spy component setup state. + * @default false + * @example + * ```ts + * const wrapper = await mountSuspended(CustomRandom, { spy: true }) + * vi.mocked(wrapper.setupState.getRandom).mockImplementation(() => 200) + * await wrapper.find('#random').trigger('click') + * expect(wrapper.setupState.getRandom).toHaveBeenCalled() + * ``` + */ + spy?: boolean scoped?: boolean } export type WrapperSuspendedResult = WrapperFnResult & { + /** + * The return value of the component setup. + */ setupState: SetupState } @@ -64,7 +84,7 @@ export function wrapperSuspended>( setProps: (props: object) => void }> { const { props = {}, attrs = {} } = options as ComponentMountingOptions - const { route = '/', scoped = false, ...wrapperFnOptions } = options as ComponentMountingOptions + const { route = '/', scoped = false, spy = false, ...wrapperFnOptions } = options as ComponentMountingOptions const vueApp: App & Record = tryUseNuxtApp()?.vueApp // @ts-expect-error untyped global __unctx__ @@ -105,7 +125,7 @@ export function wrapperSuspended>( if (!componentSetup) return - const result = scoped + let result = scoped ? await runEffectScope(() => componentSetup(props, setupContext)) : await componentSetup(props, setupContext) @@ -113,7 +133,15 @@ export function wrapperSuspended>( instanceContext.expose(wrappedInstance.exposed) } - setupState = result && typeof result === 'object' ? result : {} + if (result && typeof result === 'object') { + if (spy) { + result = vi.mockObject(result, { spy: true }) + } + setupState = result + } + else { + setupState = {} + } return result },