diff --git a/examples/app-vitest-full/components/GenericStateComponent.vue b/examples/app-vitest-full/components/GenericStateComponent.vue new file mode 100644 index 000000000..6b0fd04bd --- /dev/null +++ b/examples/app-vitest-full/components/GenericStateComponent.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/app-vitest-full/components/WatcherComponent.vue b/examples/app-vitest-full/components/WatcherComponent.vue new file mode 100644 index 000000000..8e55bfc38 --- /dev/null +++ b/examples/app-vitest-full/components/WatcherComponent.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/app-vitest-full/composables/useCounter.ts b/examples/app-vitest-full/composables/useCounter.ts new file mode 100644 index 000000000..6d8154b90 --- /dev/null +++ b/examples/app-vitest-full/composables/useCounter.ts @@ -0,0 +1,12 @@ +export const useCounter = () => { + const count = ref(0) + + function isPositive() { + return count.value > 0 + } + + return { + count, + isPositive, + } +} 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 fa894ef3f..da7f6795b 100644 --- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mountSuspended } from '@nuxt/test-utils/runtime' +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' import { satisfies } from 'semver' import { version as nuxtVersion } from 'nuxt/package.json' @@ -25,11 +25,13 @@ import ComponentWithAttrs from '~/components/ComponentWithAttrs.vue' import ComponentWithReservedProp from '~/components/ComponentWithReservedProp.vue' import ComponentWithReservedState from '~/components/ComponentWithReservedState.vue' import ComponentWithImports from '~/components/ComponentWithImports.vue' +import GenericStateComponent from '~/components/GenericStateComponent.vue' import { BoundAttrs } from '#components' import DirectiveComponent from '~/components/DirectiveComponent.vue' import CustomComponent from '~/components/CustomComponent.vue' import WrapperElement from '~/components/WrapperElement.vue' +import WatcherComponent from '~/components/WatcherComponent.vue' const formats = { ExportDefaultComponent, @@ -454,3 +456,79 @@ it('element should be changed', async () => { expect(component.element.tagName).toBe('SPAN') }) + +describe('composable state isolation', () => { + const { useCounterMock } = vi.hoisted(() => { + return { + useCounterMock: vi.fn(() => { + return { + isPositive: (): boolean => false, + } + }), + } + }) + + mockNuxtImport('useCounter', () => { + return useCounterMock + }) + + it('shows zero or negative state by default', async () => { + const component = await mountSuspended(GenericStateComponent, { scoped: true }) + expect(component.text()).toMatchInlineSnapshot('"Zero or negative count"') + }) + + it('shows positive state when counter is positive', async () => { + useCounterMock.mockRestore() + useCounterMock.mockImplementation(() => ({ + isPositive: () => true, + })) + const component = await mountSuspended(GenericStateComponent, { scoped: true }) + expect(component.text()).toMatchInlineSnapshot('"Positive count"') + }) +}) + +describe('watcher cleanup validation', () => { + let watcherCallCount = 0 + beforeEach(() => { + watcherCallCount = 0 + // Mock console.log to count watcher calls + vi.spyOn(console, 'log').mockImplementation((message) => { + if (typeof message === 'string' && message.includes('Test state has changed')) { + watcherCallCount++ + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('mounts component in test 1', async () => { + await mountSuspended(WatcherComponent, { + props: { + title: 'Component 1', + }, + scoped: true, + }) + + expect(watcherCallCount).toBe(0) + }) + + it('mounts component in test 2 and validates watcher cleanup', async () => { + await mountSuspended(WatcherComponent, { + props: { + title: 'Component 2', + }, + scoped: true, + }) + + watcherCallCount = 0 + + const state = useState('testState') + state.value = 'new state' + + await nextTick() + + expect(watcherCallCount).toBe(1) + }) +}) diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts index d6d746ba0..05706079c 100644 --- a/src/runtime-utils/mount.ts +++ b/src/runtime-utils/mount.ts @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils' import type { ComponentMountingOptions } from '@vue/test-utils' -import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance } from 'vue' +import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, effectScope } from 'vue' import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue' import { defu } from 'defu' import type { RouteLocationRaw } from 'vue-router' @@ -12,6 +12,7 @@ import { tryUseNuxtApp, useRouter } from '#imports' type MountSuspendedOptions = ComponentMountingOptions & { route?: RouteLocationRaw + scoped?: boolean } // TODO: improve return types @@ -57,6 +58,11 @@ export async function mountSuspended( ..._options } = options || {} + // cleanup previously mounted test wrappers + for (const cleanupFunction of globalThis.__cleanup || []) { + cleanupFunction() + } + const vueApp = tryUseNuxtApp()?.vueApp // @ts-expect-error untyped global __unctx__ || globalThis.__unctx__.get('nuxt-app').tryUse().vueApp @@ -104,13 +110,31 @@ export async function mountSuspended( } let passedProps: Record + let componentScope: ReturnType | null = null + const wrappedSetup = async (props: Record, setupContext: SetupContext): Promise => { interceptEmitOnCurrentInstance() passedProps = props if (setup) { - const result = await setup(props, setupContext) + let result + if (options?.scoped) { + componentScope = effectScope() + + // Add component scope cleanup to global cleanup + globalThis.__cleanup ||= [] + globalThis.__cleanup.push(() => { + componentScope?.stop() + }) + result = await componentScope?.run(async () => { + return await setup(props, setupContext) + }) + } + else { + result = await setup(props, setupContext) + } + setupState = result && typeof result === 'object' ? result : {} return result } @@ -122,10 +146,25 @@ export async function mountSuspended( { setup: (props: Record, ctx: SetupContext) => { setupContext = ctx - return NuxtRoot.setup(props, { - ...ctx, - expose: () => {}, - }) + + if (options?.scoped) { + const scope = effectScope() + + globalThis.__cleanup ||= [] + globalThis.__cleanup.push(() => { + scope.stop() + }) + return scope.run(() => NuxtRoot.setup(props, { + ...ctx, + expose: () => {}, + })) + } + else { + return NuxtRoot.setup(props, { + ...ctx, + expose: () => {}, + }) + } }, render: (renderContext: Record) => h( @@ -276,3 +315,7 @@ function wrappedMountedWrapper(wrapper: ReturnType> & { setup return proxy } + +declare global { + var __cleanup: Array<() => void> | undefined +}