diff --git a/examples/app-vitest-full/tests/nuxt/compatibility-mount.spec.ts b/examples/app-vitest-full/tests/nuxt/compatibility-mount.spec.ts index f416e5851..c5eb6446b 100644 --- a/examples/app-vitest-full/tests/nuxt/compatibility-mount.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/compatibility-mount.spec.ts @@ -649,7 +649,7 @@ describe('mountSuspended() compatible to mount()', () => { it('getCurrentComponent().proxy for compatibity for nuxt/test-utils v3.20.0', async () => { const wrapper = await mountSuspended(CompostionApi) - const proxy = wrapper.getCurrentComponent().proxy as typeof wrapper.setupState + const proxy = wrapper.getCurrentComponent().proxy as unknown as typeof wrapper.setupState expect(proxy.disabled.value).toBe(false) expect(proxy.getValue()).toBe('') diff --git a/src/browser/pure.ts b/src/browser/pure.ts index b312dd399..8d91d18f8 100644 --- a/src/browser/pure.ts +++ b/src/browser/pure.ts @@ -32,14 +32,14 @@ function ensureTestIdAttribute(element: HTMLElement) { } } -export interface RenderResult extends LocatorSelectors { +export interface RenderResult extends LocatorSelectors { container: HTMLElement baseElement: HTMLElement locator: Locator /** * The return value of the component setup, mocked when the `spy` option is enabled. */ - setupState: SetupState + setupState: SetupState debug( el?: HTMLElement | HTMLElement[] | Locator | Locator[], maxLength?: number, options?: @@ -81,7 +81,7 @@ export interface RenderResult extends LocatorSelectors { export async function render( component: T, options: WrapperOptions = {}, -): Promise>> { +): Promise, T>> { const { container, baseElement, @@ -93,7 +93,7 @@ export async function render( const { debug, getElementLocatorSelectors } = utils - const renderResult: RenderResult> = { + const renderResult: RenderResult, T> = { container, baseElement, locator: page.elementLocator(container), diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts index 4c6a8bc41..dc52b2b4d 100644 --- a/src/runtime-utils/mount.ts +++ b/src/runtime-utils/mount.ts @@ -4,7 +4,7 @@ import type { WrapperSuspendedOptions, WrapperSuspendedResult } from './utils/su type WrapperFn = typeof wrapperFn type WrapperOptions = WrapperSuspendedOptions> -type WrapperResult = WrapperSuspendedResult> +type WrapperResult = WrapperSuspendedResult, C> /** * `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: diff --git a/src/runtime-utils/render.ts b/src/runtime-utils/render.ts index 9a2b764a7..9428bdd21 100644 --- a/src/runtime-utils/render.ts +++ b/src/runtime-utils/render.ts @@ -5,7 +5,7 @@ import type { render } from '@testing-library/vue' type WrapperFn = typeof render type WrapperOptions = WrapperSuspendedOptions> -type WrapperResult = WrapperSuspendedResult> +type WrapperResult = WrapperSuspendedResult, C> /** * `renderSuspended` allows you to mount any vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. diff --git a/src/runtime-utils/utils/suspended.ts b/src/runtime-utils/utils/suspended.ts index ed3490bdd..ea6a95d13 100644 --- a/src/runtime-utils/utils/suspended.ts +++ b/src/runtime-utils/utils/suspended.ts @@ -1,6 +1,6 @@ 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 { App, ComponentInternalInstance, DefineComponent, RenderFunction, SetupContext, VNode } from 'vue' import type { RouteLocationRaw } from 'vue-router' import type { ComponentMountingOptions } from '@vue/test-utils' @@ -9,9 +9,28 @@ import NuxtRoot from '#build/root-component.mjs' import { RouterLink } from '../components/RouterLink.ts' -// TODO: improve return types +// TODO: improve return types for sfc component's non-exposed states + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type SetupState = Record +type AnySetupState = Record + +type GetDefinedSetupState< + Component, + SetupReturn = Component extends { setup?: (...args: infer _) => infer R } ? Awaited : never, + IsOptionalReturn = [Extract] extends [never] ? false : true, + DefinedSetupState = Exclude, +> = IsOptionalReturn extends true + ? Partial + : DefinedSetupState + +type GetSetupState< + Component, + DefinedSetupState = GetDefinedSetupState, +> = [DefinedSetupState] extends [never] + ? AnySetupState + : DefinedSetupState + +export type SetupState = GetSetupState & AnySetupState type BaseOptions = Pick, 'global'> @@ -59,11 +78,11 @@ export type WrapperSuspendedAdditionalOptions = { export type WrapperSuspendedOptions = WrapperFnOption & WrapperSuspendedAdditionalOptions -export type WrapperSuspendedResult = WrapperFnResult & { +export type WrapperSuspendedResult = WrapperFnResult & { /** * The return value of the component setup. */ - setupState: SetupState + setupState: SetupState } export function cleanupAll() { @@ -115,7 +134,7 @@ export function wrapperSuspended< stubRouterLink?: boolean }, ): Promise<{ - wrapper: WrapperSuspendedResult + wrapper: WrapperSuspendedResult setProps: (props: object) => void /** * Stop only the effect scopes created by this call, leaving those of other mounted components @@ -150,7 +169,7 @@ export function wrapperSuspended< let wrappedInstance: ComponentInternalInstance | null = null let setupContext: SetupContext - let setupState: SetupState = {} + let setupState = {} const setProps = reactive>({}) @@ -256,7 +275,7 @@ export function wrapperSuspended< if (isMountSettled) return isMountSettled = true - wrapper.setupState = setupState + wrapper.setupState = setupState as SetupState resolve({ wrapper, @@ -293,7 +312,7 @@ export function wrapperSuspended< }, }), }, - ) as WrapperSuspendedResult + ) as WrapperSuspendedResult }) } diff --git a/test/types/mount-render-helpers.test-d.ts b/test/types/mount-render-helpers.test-d.ts index 948b69815..0e54f9f18 100644 --- a/test/types/mount-render-helpers.test-d.ts +++ b/test/types/mount-render-helpers.test-d.ts @@ -32,8 +32,15 @@ const Component = defineComponent({ emits: ['update:modelValue'], expose: ['method'], setup: () => { + const message = ref('message') + const number = ref(0) + const object = ref({ a: 1, b: new Date(), c: true as boolean | undefined }) return { - method: () => '', + message, + number, + object, + method: () => message.value, + method2: (v: number) => number.value + v, } }, }) @@ -44,9 +51,17 @@ type SuspendedAdditionalOptions = { scoped?: boolean } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnySetupState = Record + type SuspendedAdditionalResult = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setupState: Record + setupState: { + message: Ref + number: Ref + object: Ref<{ a: number, b: Date, c: boolean | undefined }> + method: () => string + method2: (v: number) => number + } & AnySetupState } type ComponentType = typeof Component @@ -187,3 +202,125 @@ describe('browser.render', () => { }) }) }) + +describe('wrapper.setupState', () => { + type WrapperSetupStates = { + mountSuspended: Awaited>>['setupState'] + renderSuspended: Awaited>>['setupState'] + render: Awaited>>['setupState'] + } + + type ExpectedSetupStates = { + mountSuspended: T & AnySetupState + renderSuspended: T & AnySetupState + render: T & AnySetupState + } + + it('when setup returns state, infers setupState from it', () => { + const setup = () => ({ + message: ref(''), + method: () => '' as string, + }) + + type Expected = ExpectedSetupStates> + + const _c1 = defineComponent({ setup }) + expectTypeOf>().toEqualTypeOf() + + const _c2 = defineComponent({ setup: async () => setup() }) + expectTypeOf>().toEqualTypeOf() + }) + + it('when setup returns void, falls back to any setupState', () => { + const setup = (): void => {} + + type Expected = ExpectedSetupStates + + const _c1 = defineComponent({ setup }) + expectTypeOf>().toEqualTypeOf() + + const _c2 = defineComponent({ setup: async () => setup() }) + expectTypeOf>().toEqualTypeOf() + }) + + it('when setup returns undefined, falls back to any setupState', () => { + const setup = (): undefined => {} + + type Expected = ExpectedSetupStates + + const _c1 = defineComponent({ setup }) + expectTypeOf>().toEqualTypeOf() + + const _c2 = defineComponent({ setup: async () => setup() }) + expectTypeOf>().toEqualTypeOf() + }) + + it('when setup returns a union of states, keeps the union in setupState', async () => { + const setup = (props: object) => { + const method = () => '' as string + return props + ? { + tag: '1' as const, + message: ref(''), + method, + cMethod: () => new Date(), + } + : { + tag: '2' as const, + method, + cMethod: () => 1 as number, + } + } + + type Expected = ExpectedSetupStates> + + const _c1 = defineComponent({ setup }) + expectTypeOf>().toEqualTypeOf() + + const _c2 = defineComponent({ setup: async props => setup(props) }) + expectTypeOf>().toEqualTypeOf() + }) + + it('when setup may return undefined, makes setupState partial', async () => { + const setup = (props: unknown) => { + if (!props) return + return { + message: ref(''), + method: () => '' as string, + } + } + + type Expected = ExpectedSetupStates<{ + message?: Ref + method?: () => string + }> + + const _c1 = defineComponent({ setup }) + expectTypeOf>().toEqualTypeOf() + + const _c2 = defineComponent({ setup: async props => setup(props) }) + expectTypeOf>().toEqualTypeOf() + }) + + it('when setup returns a render function, falls back to any setupState', () => { + const setup = () => { + const message = ref('hello') + return () => h('div', message.value) + } + + type Expected = ExpectedSetupStates + + expectTypeOf>().toEqualTypeOf() + + const _c1 = defineComponent(setup) + expectTypeOf>().toEqualTypeOf() + + const _c2 = defineComponent({ setup }) + expectTypeOf>().toEqualTypeOf() + }) + + it('when component has no setup, falls back to any setupState', () => { + const _c = defineComponent({ render: () => h('div') }) + expectTypeOf>().toEqualTypeOf() + }) +})