Nuxt/test/nuxt/composables.test.ts

749 lines
24 KiB
TypeScript
Raw Permalink Normal View History

/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
import { describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3'
import { mount } from '@vue/test-utils'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import * as composables from '#app/composables'
import { clearNuxtData, refreshNuxtData, useAsyncData, useNuxtData } from '#app/composables/asyncData'
import { clearError, createError, isNuxtError, showError, useError } from '#app/composables/error'
import { onNuxtReady } from '#app/composables/ready'
import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders } from '#app/composables/ssr'
import { clearNuxtState, useState } from '#app/composables/state'
import { useRequestURL } from '#app/composables/url'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useId } from '#app/composables/id'
import { callOnce } from '#app/composables/once'
import { useLoadingIndicator } from '#app/composables/loading-indicator'
import { useRouteAnnouncer } from '#app/composables/route-announcer'
import { asyncDataDefaults, nuxtDefaultErrorValue } from '#build/nuxt.config.mjs'
registerEndpoint('/api/test', defineEventHandler(event => ({
method: event.method,
headers: Object.fromEntries(event.headers.entries()),
})))
describe('app config', () => {
it('can be updated', () => {
const appConfig = useAppConfig()
expect(appConfig).toMatchInlineSnapshot(`
{
"nuxt": {},
}
`)
updateAppConfig({
new: 'value',
nuxt: { nested: 42 },
})
expect(appConfig).toMatchInlineSnapshot(`
{
"new": "value",
"nuxt": {
"nested": 42,
},
}
`)
})
})
describe('composables', () => {
it('are all tested', () => {
const testedComposables: string[] = [
'clearNuxtData',
'refreshNuxtData',
'useAsyncData',
'useNuxtData',
'createError',
'isNuxtError',
'clearError',
'showError',
'useError',
'getAppManifest',
'useHydration',
'getRouteRules',
'onNuxtReady',
'callOnce',
'setResponseStatus',
'prerenderRoutes',
'useRequestEvent',
'useRequestFetch',
'isPrerendered',
'useRequestHeaders',
'useCookie',
'clearNuxtState',
'useState',
'useRequestURL',
'useId',
'useRoute',
'navigateTo',
'abortNavigation',
'setPageLayout',
'defineNuxtComponent',
]
const skippedComposables: string[] = [
'addRouteMiddleware',
'defineNuxtRouteMiddleware',
'definePayloadReducer',
'definePayloadReviver',
'loadPayload',
'onBeforeRouteLeave',
'onBeforeRouteUpdate',
'prefetchComponents',
'preloadComponents',
'preloadPayload',
'preloadRouteComponents',
'reloadNuxtApp',
'refreshCookie',
'onPrehydrate',
'useFetch',
'useHead',
'useLazyFetch',
'useLazyAsyncData',
'useRouter',
'useSeoMeta',
'useServerSeoMeta',
'usePreviewMode',
]
expect(Object.keys(composables).sort()).toEqual([...new Set([...testedComposables, ...skippedComposables])].sort())
})
})
describe('useAsyncData', () => {
it('should work at basic level', async () => {
const res = useAsyncData(() => Promise.resolve('test'))
expect(Object.keys(res)).toMatchInlineSnapshot(`
[
"data",
"pending",
"error",
"status",
"execute",
"refresh",
"clear",
]
`)
expect(res instanceof Promise).toBeTruthy()
expect(res.data.value).toBe(asyncDataDefaults.value)
await res
expect(res.data.value).toBe('test')
})
it('should not execute with immediate: false', async () => {
const immediate = await useAsyncData(() => Promise.resolve('test'))
expect(immediate.data.value).toBe('test')
expect(immediate.status.value).toBe('success')
expect(immediate.pending.value).toBe(false)
const nonimmediate = await useAsyncData(() => Promise.resolve('test'), { immediate: false })
expect(nonimmediate.data.value).toBe(asyncDataDefaults.value)
expect(nonimmediate.status.value).toBe('idle')
expect(nonimmediate.pending.value).toBe(true)
})
it('should capture errors', async () => {
const { data, error, status, pending } = await useAsyncData('error-test', () => Promise.reject(new Error('test')), { default: () => 'default' })
expect(data.value).toMatchInlineSnapshot('"default"')
expect(error.value).toMatchInlineSnapshot('[Error: test]')
expect(status.value).toBe('error')
expect(pending.value).toBe(false)
expect(useNuxtApp().payload._errors['error-test']).toMatchInlineSnapshot('[Error: test]')
// TODO: fix the below
// const { data: syncedData, error: syncedError, status: syncedStatus, pending: syncedPending } = await useAsyncData('error-test', () => ({}), { immediate: false })
// expect(syncedData.value).toEqual(null)
// expect(syncedError.value).toEqual(error.value)
// expect(syncedStatus.value).toEqual('idle')
// expect(syncedPending.value).toEqual(true)
})
// https://github.com/nuxt/nuxt/issues/23411
it('should initialize with error set to null when immediate: false', async () => {
2024-06-07 15:57:37 +00:00
const { error, execute } = useAsyncData(() => Promise.resolve({}), { immediate: false })
expect(error.value).toBe(asyncDataDefaults.errorValue)
await execute()
expect(error.value).toBe(asyncDataDefaults.errorValue)
})
it('should be accessible with useNuxtData', async () => {
await useAsyncData('key', () => Promise.resolve('test'))
const data = useNuxtData('key')
expect(data.data.value).toMatchInlineSnapshot('"test"')
clearNuxtData('key')
expect(data.data.value).toBeUndefined()
expect(useNuxtData('key').data.value).toBeUndefined()
})
it('should be usable _after_ a useNuxtData call', async () => {
useNuxtApp().payload.data.call = null
const { data: cachedData } = useNuxtData('call')
expect(cachedData.value).toMatchInlineSnapshot('null')
const { data } = await useAsyncData('call', () => Promise.resolve({ resolved: true }), { server: false })
expect(cachedData.value).toMatchInlineSnapshot(`
{
"resolved": true,
}
`)
expect(data.value).toEqual(cachedData.value)
clearNuxtData('call')
})
it('should be refreshable', async () => {
await useAsyncData('key', () => Promise.resolve('test'))
clearNuxtData('key')
const data = useNuxtData('key')
expect(data.data.value).toBeUndefined()
await refreshNuxtData('key')
expect(data.data.value).toMatchInlineSnapshot('"test"')
})
it('should be clearable', async () => {
const { data, error, pending, status, clear } = await useAsyncData(() => Promise.resolve('test'))
expect(data.value).toBe('test')
clear()
// TODO: update to asyncDataDefaults.value in v4
expect(data.value).toBeUndefined()
expect(error.value).toBe(asyncDataDefaults.errorValue)
expect(pending.value).toBe(false)
expect(status.value).toBe('idle')
})
it('allows custom access to a cache', async () => {
2024-06-07 15:57:37 +00:00
const { data } = await useAsyncData(() => Promise.resolve({ val: true }), { getCachedData: () => ({ val: false }) })
expect(data.value).toMatchInlineSnapshot(`
{
"val": false,
}
`)
})
it('should use default while pending', async () => {
const promise = useAsyncData(() => Promise.resolve('test'), { default: () => 'default' })
const { data, pending } = promise
expect(pending.value).toBe(true)
expect(data.value).toMatchInlineSnapshot('"default"')
await promise
expect(data.value).toMatchInlineSnapshot('"test"')
})
it('should use default after reject', async () => {
const { data } = await useAsyncData(() => Promise.reject(new Error('test')), { default: () => 'default' })
expect(data.value).toMatchInlineSnapshot('"default"')
})
it('should execute the promise function once when dedupe option is "defer" for multiple calls', () => {
const promiseFn = vi.fn(() => Promise.resolve('test'))
useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
expect(promiseFn).toHaveBeenCalledTimes(1)
})
it('should execute the promise function multiple times when dedupe option is not specified for multiple calls', () => {
const promiseFn = vi.fn(() => Promise.resolve('test'))
useAsyncData('dedupedKey', promiseFn)
useAsyncData('dedupedKey', promiseFn)
useAsyncData('dedupedKey', promiseFn)
expect(promiseFn).toHaveBeenCalledTimes(3)
})
it('should execute the promise function as per dedupe option when different dedupe options are used for multiple calls', () => {
const promiseFn = vi.fn(() => Promise.resolve('test'))
useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
useAsyncData('dedupedKey', promiseFn)
useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
expect(promiseFn).toHaveBeenCalledTimes(2)
})
it('should be synced with useNuxtData', async () => {
const { data: nuxtData } = useNuxtData('nuxtdata-sync')
const promise = useAsyncData('nuxtdata-sync', () => Promise.resolve('test'), { default: () => 'default' })
const { data: fetchData } = promise
expect(fetchData.value).toMatchInlineSnapshot('"default"')
nuxtData.value = 'before-fetch'
expect(fetchData.value).toMatchInlineSnapshot('"before-fetch"')
await promise
expect(fetchData.value).toMatchInlineSnapshot('"test"')
expect(nuxtData.value).toMatchInlineSnapshot('"test"')
nuxtData.value = 'new value'
expect(fetchData.value).toMatchInlineSnapshot('"new value"')
fetchData.value = 'another value'
expect(nuxtData.value).toMatchInlineSnapshot('"another value"')
})
})
describe('useFetch', () => {
it('should match with/without computed values', async () => {
const nuxtApp = useNuxtApp()
const getPayloadEntries = () => Object.keys(nuxtApp.payload.data).length
const baseCount = getPayloadEntries()
await useFetch('/api/test')
expect(getPayloadEntries()).toBe(baseCount + 1)
/* @ts-expect-error Overriding auto-key */
await useFetch('/api/test', { method: 'POST' }, '')
/* @ts-expect-error Overriding auto-key */
await useFetch('/api/test', { method: ref('POST') }, '')
expect.soft(getPayloadEntries()).toBe(baseCount + 2)
/* @ts-expect-error Overriding auto-key */
await useFetch('/api/test', { query: { id: '3' } }, '')
/* @ts-expect-error Overriding auto-key */
await useFetch('/api/test', { query: { id: ref('3') } }, '')
/* @ts-expect-error Overriding auto-key */
await useFetch('/api/test', { params: { id: '3' } }, '')
/* @ts-expect-error Overriding auto-key */
await useFetch('/api/test', { params: { id: ref('3') } }, '')
expect.soft(getPayloadEntries()).toBe(baseCount + 3)
})
it('should timeout', async () => {
const { status, error } = await useFetch(
2024-06-07 15:57:37 +00:00
// @ts-expect-error should resolve to a string
() => new Promise(resolve => setTimeout(resolve, 5000)),
{ timeout: 1 },
)
await new Promise(resolve => setTimeout(resolve, 2))
expect(status.value).toBe('error')
expect(error.value).toMatchInlineSnapshot('[Error: [GET] "[object Promise]": <no response> The operation was aborted.]')
})
})
describe('errors', () => {
it('createError', () => {
expect(createError({ statusCode: 404 }).toJSON()).toMatchInlineSnapshot(`
{
"message": "",
"statusCode": 404,
}
`)
expect(createError('Message').toJSON()).toMatchInlineSnapshot(`
{
"message": "Message",
"statusCode": 500,
}
`)
})
it('isNuxtError', () => {
const error = createError({ statusCode: 404 })
expect(isNuxtError(error)).toBe(true)
expect(isNuxtError(new Error('test'))).toBe(false)
})
it('global nuxt errors', () => {
const error = useError()
expect(error.value).toBeUndefined()
showError('new error')
expect(error.value).toMatchInlineSnapshot('[Error: new error]')
clearError()
expect(error.value).toBe(nuxtDefaultErrorValue)
})
})
describe('onNuxtReady', () => {
it('should call callback once nuxt is hydrated', async () => {
const fn = vi.fn()
onNuxtReady(fn)
await new Promise(resolve => setTimeout(resolve, 1))
expect(fn).toHaveBeenCalled()
})
})
describe('ssr composables', () => {
it('work on client', () => {
// @ts-expect-error This should work for backward compatibility
expect(setResponseStatus()).toBeUndefined()
expect(useRequestEvent()).toBeUndefined()
expect(useRequestFetch()).toEqual($fetch)
expect(useRequestHeaders()).toEqual({})
expect(prerenderRoutes('/')).toBeUndefined()
})
})
describe('useHydration', () => {
it('should hydrate value from payload', async () => {
let val: any
const nuxtApp = useNuxtApp()
useHydration('key', () => {}, (fromPayload) => { val = fromPayload })
await nuxtApp.hooks.callHook('app:created', nuxtApp.vueApp)
expect(val).toMatchInlineSnapshot('undefined')
nuxtApp.payload.key = 'from payload'
await nuxtApp.hooks.callHook('app:created', nuxtApp.vueApp)
expect(val).toMatchInlineSnapshot('"from payload"')
})
})
describe('useState', () => {
it('default', () => {
expect(useState(() => 'default').value).toBe('default')
})
it('registers state in payload', () => {
useState('key', () => 'value')
expect(Object.entries(useNuxtApp().payload.state)).toContainEqual(['$skey', 'value'])
})
})
describe('clearNuxtState', () => {
it('clears state in payload for single key', () => {
const key = 'clearNuxtState-test'
const state = useState(key, () => 'test')
expect(state.value).toBe('test')
clearNuxtState(key)
expect(state.value).toBeUndefined()
})
it('clears state in payload for array of keys', () => {
const key1 = 'clearNuxtState-test'
const key2 = 'clearNuxtState-test2'
const state1 = useState(key1, () => 'test')
const state2 = useState(key2, () => 'test')
expect(state1.value).toBe('test')
expect(state2.value).toBe('test')
clearNuxtState([key1, 'other'])
expect(state1.value).toBeUndefined()
expect(state2.value).toBe('test')
clearNuxtState([key1, key2])
expect(state1.value).toBeUndefined()
expect(state2.value).toBeUndefined()
})
it('clears state in payload for function', () => {
const key = 'clearNuxtState-test'
const state = useState(key, () => 'test')
expect(state.value).toBe('test')
clearNuxtState(() => false)
expect(state.value).toBe('test')
clearNuxtState(k => k === key)
expect(state.value).toBeUndefined()
})
it('clears all state when no key is provided', () => {
const state1 = useState('clearNuxtState-test', () => 'test')
const state2 = useState('clearNuxtState-test2', () => 'test')
expect(state1.value).toBe('test')
expect(state2.value).toBe('test')
clearNuxtState()
expect(state1.value).toBeUndefined()
expect(state2.value).toBeUndefined()
})
})
describe('useId', () => {
it('default', () => {
const vals = new Set<string>()
for (let index = 0; index < 100; index++) {
mount(defineComponent({
setup () {
const id = useId()
vals.add(id)
return () => h('div', id)
},
}))
}
expect(vals.size).toBe(100)
})
it('generates unique ids per-component', () => {
const component = defineComponent({
setup () {
const id = useId()
return () => h('div', id)
},
})
expect(mount(component).html()).not.toBe(mount(component).html())
})
})
describe('url', () => {
it('useRequestURL', () => {
const url = useRequestURL()
expect(url).toMatchInlineSnapshot('"http://localhost:3000/"')
expect(url.hostname).toMatchInlineSnapshot('"localhost"')
expect(url.port).toMatchInlineSnapshot('"3000"')
expect(url.protocol).toMatchInlineSnapshot('"http:"')
})
})
describe('loading state', () => {
it('expect loading state to be changed by hooks', async () => {
vi.stubGlobal('setTimeout', vi.fn((cb: Function) => cb()))
const nuxtApp = useNuxtApp()
const { isLoading } = useLoadingIndicator()
expect(isLoading.value).toBeFalsy()
await nuxtApp.callHook('page:loading:start')
expect(isLoading.value).toBeTruthy()
await nuxtApp.callHook('page:loading:end')
expect(isLoading.value).toBeFalsy()
vi.mocked(setTimeout).mockRestore()
})
})
describe('loading state', () => {
it('expect loading state to be changed by force starting/stoping', async () => {
vi.stubGlobal('setTimeout', vi.fn((cb: Function) => cb()))
const nuxtApp = useNuxtApp()
const { isLoading, start, finish } = useLoadingIndicator()
expect(isLoading.value).toBeFalsy()
await nuxtApp.callHook('page:loading:start')
expect(isLoading.value).toBeTruthy()
start()
expect(isLoading.value).toBeTruthy()
finish()
expect(isLoading.value).toBeFalsy()
})
})
describe('loading state', () => {
it('expect error from loading state to be changed by finish({ error: true })', async () => {
vi.stubGlobal('setTimeout', vi.fn((cb: Function) => cb()))
const nuxtApp = useNuxtApp()
const { error, start, finish } = useLoadingIndicator()
expect(error.value).toBeFalsy()
await nuxtApp.callHook('page:loading:start')
start()
finish({ error: true })
expect(error.value).toBeTruthy()
start()
expect(error.value).toBeFalsy()
finish()
})
})
describe.skipIf(process.env.TEST_MANIFEST === 'manifest-off')('app manifests', () => {
it('getAppManifest', async () => {
const manifest = await getAppManifest()
2024-06-07 15:57:37 +00:00
// @ts-expect-error timestamp is not optional
delete manifest.timestamp
expect(manifest).toMatchInlineSnapshot(`
{
"id": "override",
"matcher": {
"dynamic": {},
"static": {
"/": null,
"/pre": null,
"/pre/test": {
"redirect": true,
},
},
"wildcard": {
"/pre": {
"prerender": true,
},
},
},
"prerendered": [
"/specific-prerendered",
],
}
`)
})
it('getRouteRules', async () => {
expect(await getRouteRules('/')).toMatchInlineSnapshot('{}')
expect(await getRouteRules('/pre')).toMatchInlineSnapshot(`
{
"prerender": true,
}
`)
expect(await getRouteRules('/pre/test')).toMatchInlineSnapshot(`
{
"prerender": true,
"redirect": true,
}
`)
})
it('isPrerendered', async () => {
expect(await isPrerendered('/specific-prerendered')).toBeTruthy()
expect(await isPrerendered('/prerendered/test')).toBeFalsy()
expect(await isPrerendered('/test')).toBeFalsy()
expect(await isPrerendered('/pre/test')).toBeFalsy()
expect(await isPrerendered('/pre/thing')).toBeTruthy()
})
})
describe('routing utilities: `navigateTo`', () => {
it('navigateTo should disallow navigation to external URLs by default', () => {
expect(() => navigateTo('https://test.com')).toThrowErrorMatchingInlineSnapshot('[Error: Navigating to an external URL is not allowed by default. Use `navigateTo(url, { external: true })`.]')
expect(() => navigateTo('https://test.com', { external: true })).not.toThrow()
})
it('navigateTo should disallow navigation to data/script URLs', () => {
const urls = [
['data:alert("hi")', 'data'],
['\0data:alert("hi")', 'data'],
]
for (const [url, protocol] of urls) {
expect(() => navigateTo(url, { external: true })).toThrowError(`Cannot navigate to a URL with '${protocol}:' protocol.`)
}
})
})
describe('routing utilities: `useRoute`', () => {
it('should show provide a mock route', () => {
expect(useRoute()).toMatchObject({
fullPath: '/',
hash: '',
href: '/',
matched: [],
meta: {},
name: undefined,
params: {},
path: '/',
query: {},
redirectedFrom: undefined,
})
})
})
describe('routing utilities: `abortNavigation`', () => {
it('should throw an error if one is provided', () => {
const error = useError()
expect(() => abortNavigation({ message: 'Page not found' })).toThrowErrorMatchingInlineSnapshot('[Error: Page not found]')
expect(error.value).toBe(nuxtDefaultErrorValue)
})
it('should block navigation if no error is provided', () => {
expect(abortNavigation()).toMatchInlineSnapshot('false')
})
})
describe('routing utilities: `setPageLayout`', () => {
it('should set layout on page metadata if run outside middleware', () => {
const route = useRoute()
expect(route.meta.layout).toBeUndefined()
setPageLayout('custom')
expect(route.meta.layout).toEqual('custom')
route.meta.layout = undefined
})
it('should not set layout directly if run within middleware', () => {
const route = useRoute()
const nuxtApp = useNuxtApp()
nuxtApp._processingMiddleware = true
setPageLayout('custom')
expect(route.meta.layout).toBeUndefined()
nuxtApp._processingMiddleware = false
})
})
describe('defineNuxtComponent', () => {
it('should produce a Vue component', async () => {
const wrapper = await mountSuspended(defineNuxtComponent({
render: () => h('div', 'hi there'),
}))
expect(wrapper.html()).toMatchInlineSnapshot('"<div>hi there</div>"')
})
it.todo('should support Options API asyncData')
it.todo('should support Options API head')
})
describe('useCookie', () => {
it('should watch custom cookie refs', () => {
const user = useCookie('userInfo', {
default: () => ({ score: -1 }),
maxAge: 60 * 60,
})
const computedVal = computed(() => user.value.score)
expect(computedVal.value).toBe(-1)
user.value.score++
expect(computedVal.value).toBe(0)
})
it('should not watch custom cookie refs when shallow', () => {
for (const value of ['shallow', false] as const) {
const user = useCookie('shallowUserInfo', {
default: () => ({ score: -1 }),
maxAge: 60 * 60,
watch: value,
})
const computedVal = computed(() => user.value.score)
expect(computedVal.value).toBe(-1)
user.value.score++
expect(computedVal.value).toBe(-1)
}
})
})
describe('callOnce', () => {
it('should only call composable once', async () => {
const fn = vi.fn()
const execute = () => callOnce(fn)
await execute()
await execute()
expect(fn).toHaveBeenCalledTimes(1)
})
it('should only call composable once when called in parallel', async () => {
const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1)))
const execute = () => callOnce(fn)
await Promise.all([execute(), execute(), execute()])
expect(fn).toHaveBeenCalledTimes(1)
const fnSync = vi.fn().mockImplementation(() => {})
const executeSync = () => callOnce(fnSync)
await Promise.all([executeSync(), executeSync(), executeSync()])
expect(fnSync).toHaveBeenCalledTimes(1)
})
it('should use key to dedupe', async () => {
const fn = vi.fn()
const execute = (key?: string) => callOnce(key, fn)
await execute('first')
await execute('first')
await execute('second')
expect(fn).toHaveBeenCalledTimes(2)
})
})
describe('route announcer', () => {
it('should create a route announcer with default politeness', () => {
const announcer = useRouteAnnouncer()
expect(announcer.politeness.value).toBe('polite')
})
it('should create a route announcer with provided politeness', () => {
const announcer = useRouteAnnouncer({ politeness: 'assertive' })
expect(announcer.politeness.value).toBe('assertive')
})
it('should set message and politeness', () => {
const announcer = useRouteAnnouncer()
announcer.set('Test message with politeness', 'assertive')
expect(announcer.message.value).toBe('Test message with politeness')
expect(announcer.politeness.value).toBe('assertive')
})
it('should set message with polite politeness', () => {
const announcer = useRouteAnnouncer()
announcer.polite('Test message polite')
expect(announcer.message.value).toBe('Test message polite')
expect(announcer.politeness.value).toBe('polite')
})
it('should set message with assertive politeness', () => {
const announcer = useRouteAnnouncer()
announcer.assertive('Test message assertive')
expect(announcer.message.value).toBe('Test message assertive')
expect(announcer.politeness.value).toBe('assertive')
})
})