import { readdir } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { joinURL, withQuery } from 'ufo'
import { isCI, isWindows } from 'std-env'
import { join, normalize } from 'pathe'
import { $fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e'
import { $fetchComponent } from '@nuxt/test-utils/experimental'
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
import type { NuxtIslandResponse } from '#app'
const isWebpack = process.env.TEST_BUILDER === 'webpack'
const isTestingAppManifest = process.env.TEST_MANIFEST !== 'manifest-off'
const isV4 = process.env.TEST_V4 === 'true'
await setup({
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
dev: process.env.TEST_ENV === 'dev',
server: true,
browser: true,
setupTimeout: (isWindows ? 360 : 120) * 1000,
nuxtConfig: {
builder: isWebpack ? 'webpack' : 'vite',
buildDir: process.env.NITRO_BUILD_DIR,
nitro: { output: { dir: process.env.NITRO_OUTPUT_DIR } },
},
})
describe('server api', () => {
it('should serialize', async () => {
expect(await $fetch('/api/hello')).toBe('Hello API')
expect(await $fetch('/api/hey')).toEqual({
foo: 'bar',
baz: 'qux',
})
})
it('should preserve states', async () => {
expect(await $fetch('/api/counter')).toEqual({ count: 0 })
expect(await $fetch('/api/counter')).toEqual({ count: 1 })
expect(await $fetch('/api/counter')).toEqual({ count: 2 })
expect(await $fetch('/api/counter')).toEqual({ count: 3 })
})
it('should auto-import', async () => {
const res = await $fetch('/api/auto-imports')
expect(res).toMatchInlineSnapshot(`
{
"autoImported": "utils",
"fromServerDir": "test-utils",
"thisIs": "serverAutoImported",
}
`)
})
})
describe('route rules', () => {
it('should enable spa mode', async () => {
const headHtml = await $fetch('/route-rules/spa')
// SPA should render appHead tags
expect(headHtml).toContain('')
expect(headHtml).toContain('')
expect(headHtml).toContain('')
const { script, attrs } = parseData(headHtml)
expect(script.serverRendered).toEqual(false)
if (isRenderingJson) {
expect(attrs['data-ssr']).toEqual('false')
}
await expectNoClientErrors('/route-rules/spa')
})
it('should not render loading template in spa mode if it is not enabled', async () => {
const html = await $fetch('/route-rules/spa')
expect(html).toContain('
')
})
it('should allow defining route rules inline', async () => {
const res = await fetch('/route-rules/inline')
expect(res.status).toEqual(200)
expect(res.headers.get('x-extend')).toEqual('added in routeRules')
})
it('test noScript routeRules', async () => {
const html = await $fetch('/no-scripts')
expect(html).not.toContain('
*
*
* ```
*/
const { id1, id2 } = html.match(/]* data-prehydrate-id=":(?
[^:]+)::(?[^:]+):"> onPrehydrate testing <\/div>/)?.groups || {}
expect(id1).toBeTruthy()
const matches = [
html.match(/`)),
html.match(new RegExp(``)),
]
// This tests we inject all scripts correctly, and only have one occurrence of multiple calls of a composable
expect(matches.every(s => s?.length === 1)).toBeTruthy()
// Check for hydration/syntax errors on client side
await expectNoClientErrors('/composables/on-prehydrate')
})
it('respects preview mode with a token', async () => {
const token = 'hehe'
const page = await createPage(`/preview?preview=true&token=${token}`)
const hasRerunFetchOnClient = await new Promise((resolve) => {
page.on('console', (message) => {
setTimeout(() => resolve(false), 4000)
if (message.text() === 'true') { resolve(true) }
})
})
expect(hasRerunFetchOnClient).toBe(true)
expect(await page.locator('#fetched-on-client').textContent()).toContain('fetched on client')
expect(await page.locator('#preview-mode').textContent()).toContain('preview mode enabled')
await page.click('#use-fetch-check')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath.includes('/preview/with-use-fetch'))
expect(await page.locator('#token-check').textContent()).toContain(token)
expect(await page.locator('#correct-api-key-check').textContent()).toContain('true')
await page.close()
})
it('respects preview mode with custom state', async () => {
const { page } = await renderPage('/preview/with-custom-state?preview=true')
expect(await page.locator('#data1').textContent()).toContain('data1 updated')
expect(await page.locator('#data2').textContent()).toContain('data2')
await page.click('#toggle-preview') // manually turns off preview mode
await page.click('#with-use-fetch')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath.includes('/preview/with-use-fetch'))
expect(await page.locator('#enabled').textContent()).toContain('false')
expect(await page.locator('#token-check').textContent()).toEqual('')
expect(await page.locator('#correct-api-key-check').textContent()).toContain('false')
await page.close()
})
it('respects preview mode with custom enable', async () => {
const { page } = await renderPage('/preview/with-custom-enable?preview=true')
expect(await page.locator('#enabled').textContent()).toContain('false')
await page.close()
})
it('respects preview mode with custom enable and customPreview', async () => {
const { page } = await renderPage('/preview/with-custom-enable?customPreview=true')
expect(await page.locator('#enabled').textContent()).toContain('true')
await page.close()
})
})
describe('rich payloads', () => {
it('correctly serializes and revivifies complex types', async () => {
const html = await $fetch('/json-payload')
for (const test of [
'Date: true',
'BigInt: true',
'Error: true',
'Shallow reactive: true',
'Shallow ref: true',
'Undefined ref: true',
'BigInt ref:',
'Reactive: true',
'Ref: true',
'Recursive objects: true',
]) {
expect(html).toContain(test)
}
})
})
describe('nuxt links', () => {
it('handles trailing slashes', async () => {
const html = await $fetch('/nuxt-link/trailing-slash')
const data: Record = {}
for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) {
data[selector] = []
for (const match of html.matchAll(new RegExp(`href="([^"]*)"[^>]*class="[^"]*\\b${selector}\\b`, 'g'))) {
data[selector].push(match[1])
}
}
expect(data).toMatchInlineSnapshot(`
{
"link-with-trailing-slash": [
"/",
"/nuxt-link/trailing-slash/",
"/nuxt-link/trailing-slash/",
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/",
"/nuxt-link/trailing-slash/?with-state=true",
"/nuxt-link/trailing-slash/?without-state=true",
],
"link-without-trailing-slash": [
"/",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash?with-state=true",
"/nuxt-link/trailing-slash?without-state=true",
],
"nuxt-link": [
"/",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash/",
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash?with-state=true",
"/nuxt-link/trailing-slash?without-state=true",
],
"router-link": [
"/",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash/",
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
"/nuxt-link/trailing-slash",
"/nuxt-link/trailing-slash?with-state=true",
"/nuxt-link/trailing-slash?without-state=true",
],
}
`)
})
it('preserves route state', async () => {
const { page } = await renderPage('/nuxt-link/trailing-slash')
for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) {
await page.locator(`.${selector}[href*=with-state]`).click()
await page.getByTestId('window-state').getByText('bar').waitFor()
await page.locator(`.${selector}[href*=without-state]`).click()
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath.includes('without-state'))
expect(await page.getByTestId('window-state').innerText()).not.toContain('bar')
}
await page.close()
})
it('expect scroll to top on routes with same component', async () => {
// #22402
const page = await createPage('/big-page-1', {
viewport: {
width: 1000,
height: 1000,
},
})
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/big-page-1')
await page.locator('#big-page-2').scrollIntoViewIfNeeded()
await page.waitForFunction(() => window.scrollY > 0)
await page.locator('#big-page-2').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/big-page-2')
await page.waitForFunction(() => window.scrollY === 0)
await page.locator('#big-page-1').scrollIntoViewIfNeeded()
await page.waitForFunction(() => window.scrollY > 0)
await page.locator('#big-page-1').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/big-page-1')
await page.waitForFunction(() => window.scrollY === 0)
await page.close()
})
it('expect scroll to top on nested pages', async () => {
// #20523
const page = await createPage('/nested/foo/test', {
viewport: {
width: 1000,
height: 1000,
},
})
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nested/foo/test')
await page.locator('#user-test').scrollIntoViewIfNeeded()
await page.waitForFunction(() => window.scrollY > 0)
await page.locator('#user-test').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nested/foo/user-test')
await page.waitForFunction(() => window.scrollY === 0)
await page.locator('#test').scrollIntoViewIfNeeded()
await page.waitForFunction(() => window.scrollY > 0)
await page.locator('#test').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nested/foo/test')
await page.waitForFunction(() => window.scrollY === 0)
await page.close()
})
it('useLink works', async () => {
const html = await $fetch('/nuxt-link/use-link')
expect(html).toContain('useLink in NuxtLink: true
')
expect(html).toContain('route using useLink: /nuxt-link/trailing-slash
')
expect(html).toContain('href using useLink: /nuxt-link/trailing-slash
')
expect(html).toContain('useLink2 in NuxtLink: true
')
expect(html).toContain('route2 using useLink: /nuxt-link/trailing-slash
')
expect(html).toContain('href2 using useLink: /nuxt-link/trailing-slash
')
expect(html).toContain('useLink3 in NuxtLink: true
')
expect(html).toContain('route3 using useLink: /nuxt-link/trailing-slash
')
expect(html).toContain('href3 using useLink: /nuxt-link/trailing-slash
')
})
it('useLink navigate importing NuxtLink works', async () => {
const page = await createPage('/nuxt-link/use-link')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/nuxt-link/use-link')
await page.locator('#button1').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nuxt-link/trailing-slash')
await page.close()
})
it('useLink navigate using resolveComponent works', async () => {
const page = await createPage('/nuxt-link/use-link')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/nuxt-link/use-link')
await page.locator('#button2').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nuxt-link/trailing-slash')
await page.close()
})
it('useLink navigate using resolveDynamicComponent works', async () => {
const page = await createPage('/nuxt-link/use-link')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/nuxt-link/use-link')
await page.locator('#button3').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nuxt-link/trailing-slash')
await page.close()
})
})
describe('head tags', () => {
it('SSR should render tags', async () => {
const headHtml = await $fetch('/head')
expect(headHtml).toContain('Using a dynamic component - Title Template Fn Change')
expect(headHtml).not.toContain('')
expect(headHtml).toContain('')
expect(headHtml.match('meta charset').length).toEqual(1)
expect(headHtml).toContain('')
expect(headHtml.match('meta name="viewport"').length).toEqual(1)
expect(headHtml).not.toContain('')
expect(headHtml).toContain('')
expect(headHtml).toMatch(/]*class="html-attrs-test"/)
expect(headHtml).toMatch(/]*class="body-attrs-test"/)
expect(headHtml).toContain('')
const indexHtml = await $fetch('/')
// should render charset by default
expect(indexHtml).toContain('')
// should render components
expect(indexHtml).toContain('Basic fixture')
})
it('SSR script setup should render tags', async () => {
const headHtml = await $fetch('/head-script-setup')
// useHead - title & titleTemplate are working
expect(headHtml).toContain('head script setup - Nuxt Playground')
// useSeoMeta - template params
expect(headHtml).toContain('')
// useSeoMeta - refs
expect(headHtml).toContain('')
// useServerHead - shorthands
expect(headHtml).toContain('>/* Custom styles */')
// useHeadSafe - removes dangerous content
expect(headHtml).toContain('')
expect(headHtml).toContain('')
})
it.skipIf(isV4)('SPA should render appHead tags', async () => {
const headHtml = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } })
expect(headHtml).toContain('')
expect(headHtml).toContain('')
expect(headHtml).toContain('')
})
it.skipIf(isV4)('legacy vueuse/head works', async () => {
const headHtml = await $fetch('/vueuse-head')
expect(headHtml).toContain('using provides usehead and updateDOM - VueUse head polyfill test')
})
it('should render http-equiv correctly', async () => {
const html = await $fetch('/head')
// http-equiv should be rendered kebab case
expect(html).toContain('')
})
// TODO: Doesn't adds header in test environment
// it.todo('should render stylesheet link tag (SPA mode)', async () => {
// const html = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } })
// expect(html).toMatch(/ {
it('should work with defineNuxtComponent', async () => {
const html = await $fetch('/legacy/async-data')
expect(html).toContain('Hello API
')
expect(html).toContain('fooChild
')
expect(html).toContain('fooParent
')
const { script } = parseData(html)
expect(script.data['options:asyncdata:hello'].hello).toBe('Hello API')
expect(Object.values(script.data)).toMatchInlineSnapshot(`
[
{
"baz": "qux",
"foo": "bar",
},
{
"hello": "Hello API",
},
{
"fooParent": "fooParent",
},
{
"fooChild": "fooChild",
},
]
`)
})
})
describe('navigate', () => {
it('should redirect to index with navigateTo', async () => {
const { headers, status } = await fetch('/navigate-to/', { redirect: 'manual' })
expect(headers.get('location')).toEqual('/')
expect(status).toEqual(301)
})
it('respects redirects + headers in middleware', async () => {
const res = await fetch('/navigate-some-path/', { redirect: 'manual', headers: { 'trailing-slash': 'true' } })
expect(res.headers.get('location')).toEqual('/navigate-some-path')
expect(res.status).toEqual(307)
expect(await res.text()).toMatchInlineSnapshot('""')
})
it('should not overwrite headers', async () => {
const { headers, status } = await fetch('/navigate-to-external', { redirect: 'manual' })
expect(headers.get('location')).toEqual('/')
expect(status).toEqual(302)
})
it('should not run setup function in path redirected to', async () => {
const { headers, status } = await fetch('/navigate-to-error', { redirect: 'manual' })
expect(headers.get('location')).toEqual('/setup-should-not-run')
expect(status).toEqual(302)
})
it('supports directly aborting navigation on SSR', async () => {
const { status } = await fetch('/navigate-to-false', { redirect: 'manual' })
expect(status).toEqual(404)
})
})
describe('preserves current instance', () => {
// TODO: it's unclear why there's an error here but it must be an upstream issue
it.todo('should not return getCurrentInstance when there\'s an error in data', async () => {
await fetch('/instance/error')
const html = await $fetch('/instance/next-request')
expect(html).toContain('This should be false: false')
})
// TODO: re-enable when https://github.com/nuxt/nuxt/issues/15164 is resolved
it.skipIf(isWindows)('should not lose current nuxt app after await in vue component', async () => {
const requests = await Promise.all(Array.from({ length: 100 }).map(() => $fetch('/instance/next-request')))
for (const html of requests) {
expect(html).toContain('This should be true: true')
}
})
})
describe('errors', () => {
it('should render a JSON error page', async () => {
const res = await fetch('/error', {
headers: {
accept: 'application/json',
},
})
expect(res.status).toBe(422)
expect(res.statusText).toBe('This is a custom error')
const error = await res.json()
delete error.stack
expect(error).toMatchObject({
message: 'This is a custom error',
statusCode: 422,
statusMessage: 'This is a custom error',
url: '/error',
})
})
it('should render a HTML error page', async () => {
const res = await fetch('/error')
expect(res.headers.get('Set-Cookie')).toBe('set-in-plugin=true; Path=/, some-error=was%20set; Path=/')
expect(await res.text()).toContain('This is a custom error')
})
it('should not allow accessing error route directly', async () => {
const res = await fetch('/__nuxt_error', {
headers: {
accept: 'application/json',
},
})
expect(res.status).toBe(404)
const error = await res.json()
delete error.stack
expect(error).toMatchInlineSnapshot(`
{
"message": "Page Not Found: /__nuxt_error",
"statusCode": 404,
"statusMessage": "Page Not Found: /__nuxt_error",
"url": "/__nuxt_error",
}
`)
})
it('should not recursively throw an error when there is an error rendering the error page', async () => {
const res = await $fetch('/', {
headers: {
'x-test-recurse-error': 'true',
'accept': 'text/html',
},
})
expect(typeof res).toBe('string')
expect(res).toContain('Hello Nuxt 3!')
})
// TODO: need to create test for webpack
it.runIf(!isDev() && !isWebpack)('should handle chunk loading errors', async () => {
const { page, consoleLogs } = await renderPage('/')
await page.getByText('Increment state').click()
await page.getByText('Increment state').click()
expect(await page.innerText('div')).toContain('Some value: 3')
await page.getByText('Chunk error').click()
await page.waitForURL(url('/chunk-error'))
expect(consoleLogs.map(c => c.text).join('')).toContain('caught chunk load error')
expect(await page.innerText('div')).toContain('Chunk error page')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/chunk-error')
await page.locator('div').getByText('State: 3').waitFor()
await page.close()
})
})
describe('navigate external', () => {
it('should redirect to example.com', async () => {
const { headers } = await fetch('/navigate-to-external/', { redirect: 'manual' })
expect(headers.get('location')).toEqual('https://example.com/?redirect=false#test')
})
it('should redirect to api endpoint', async () => {
const { headers } = await fetch('/navigate-to-api', { redirect: 'manual' })
expect(headers.get('location')).toEqual('/api/test')
})
})
describe('composables', () => {
it('`callOnce` should run code once', async () => {
const html = await $fetch('/once')
expect(html).toContain('once.vue')
expect(html).toContain('once: 2')
const { page } = await renderPage('/once')
expect(await page.getByText('once:').textContent()).toContain('once: 2')
})
it('`useId` should generate unique ids', async () => {
// TODO: work around interesting Vue bug where async components are loaded in a different order on first import
await $fetch('/use-id')
const sanitiseHTML = (html: string) => html.replace(/ data-[^= ]+="[^"]+"/g, '').replace(//, '')
const serverHTML = await $fetch('/use-id').then(html => sanitiseHTML(html.match(//)![0]))
const ids = serverHTML.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1')) as string[]
const renderedForm = [
` id: ${ids[0]}
`,
``,
]
const clientOnlyServer = ''
expect(serverHTML).toEqual(``)
const { page, pageErrors } = await renderPage('/use-id')
const clientHTML = await page.innerHTML('form')
const clientIds = clientHTML
.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1'))
.filter(i => !ids.includes(i)) as string[]
const clientOnlyClient = ``
expect(sanitiseHTML(clientHTML)).toEqual(`${renderedForm.join(clientOnlyClient)}`)
expect(pageErrors).toEqual([])
await page.close()
})
it('`useRouteAnnouncer` should change message on route change', async () => {
const { page } = await renderPage('/route-announcer')
expect(await page.getByRole('alert').textContent()).toContain('First Page')
await page.getByRole('link').click()
await page.getByText('Second page content').waitFor()
expect(await page.getByRole('alert').textContent()).toContain('Second Page')
await page.close()
})
it('`useRouteAnnouncer` should change message on dynamically changed title', async () => {
const { page } = await renderPage('/route-announcer')
await page.getByRole('button').click()
await page.waitForFunction(() => document.title.includes('Dynamically set title'))
expect(await page.getByRole('alert').textContent()).toContain('Dynamically set title')
await page.close()
})
})
describe('middlewares', () => {
it('should redirect to index with global middleware', async () => {
const html = await $fetch('/redirect/')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('Hello Nuxt 3!')
})
it('should allow aborting navigation on server-side', async () => {
const res = await fetch('/?abort', {
headers: {
accept: 'application/json',
},
})
expect(res.status).toEqual(401)
})
it('should allow aborting navigation fatally on client-side', async () => {
const html = await $fetch('/middleware-abort')
expect(html).not.toContain('This is the error page')
const { page } = await renderPage('/middleware-abort')
expect(await page.innerHTML('body')).toContain('This is the error page')
await page.close()
})
it('should inject auth', async () => {
const html = await $fetch('/auth')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('auth.vue')
expect(html).toContain('auth: Injected by injectAuth middleware')
})
it('should not inject auth', async () => {
const html = await $fetch('/no-auth')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('no-auth.vue')
expect(html).toContain('auth: ')
expect(html).not.toContain('Injected by injectAuth middleware')
})
it('should redirect to index with http 307 with navigateTo on server side', async () => {
const html = await fetch('/navigate-to-redirect', { redirect: 'manual' })
expect(html.headers.get('location')).toEqual('/')
expect(html.status).toEqual(307)
})
})
describe('plugins', () => {
it('basic plugin', async () => {
const html = await $fetch('/plugins')
expect(html).toContain('myPlugin: Injected by my-plugin')
})
it('async plugin', async () => {
const html = await $fetch('/plugins')
expect(html).toContain('asyncPlugin: Async plugin works! 123')
expect(html).toContain('useFetch works!')
})
})
describe('layouts', () => {
it('should apply custom layout', async () => {
const html = await $fetch('/with-layout')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('with-layout.vue')
expect(html).toContain('Custom Layout:')
})
it('should work with a dynamically set layout', async () => {
const html = await $fetch('/with-dynamic-layout')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('with-dynamic-layout')
expect(html).toContain('Custom Layout:')
await expectNoClientErrors('/with-dynamic-layout')
})
it('should work with a computed layout', async () => {
const html = await $fetch('/with-computed-layout')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('with-computed-layout')
expect(html).toContain('Custom Layout')
await expectNoClientErrors('/with-computed-layout')
})
it('should allow passing custom props to a layout', async () => {
const html = await $fetch('/layouts/with-props')
expect(html).toContain('some prop was passed')
await expectNoClientErrors('/layouts/with-props')
})
})
describe('composable tree shaking', () => {
it('should work', async () => {
const html = await $fetch('/tree-shake')
expect(html).toContain('Tree Shake Example')
const { page, pageErrors } = await renderPage('/tree-shake')
// ensure scoped classes are correctly assigned between client and server
expect(await page.$eval('h1', e => getComputedStyle(e).color)).toBe('rgb(255, 192, 203)')
expect(pageErrors).toEqual([])
await page.close()
})
})
describe('ignore list', () => {
it('should ignore composable files in .nuxtignore', async () => {
const html = await $fetch('/ignore/composables')
expect(html).toContain('was import ignored: true')
})
it('should ignore scanned nitro handlers in .nuxtignore', async () => {
const html = await $fetch('/ignore/scanned')
expect(html).not.toContain('this should be ignored')
})
it.skipIf(isDev())('should ignore public assets in .nuxtignore', async () => {
const html = await $fetch('/ignore/public-asset')
expect(html).not.toContain('this should be ignored')
})
})
describe('server tree shaking', () => {
it('should work', async () => {
const html = await $fetch('/client')
expect(html).toContain('This page should not crash when rendered')
expect(html).toContain('fallback for ClientOnly')
expect(html).not.toContain('rendered client-side')
expect(html).not.toContain('id="client-side"')
const { page } = await renderPage('/client')
await page.waitForFunction(() => window.useNuxtApp?.())
// ensure scoped classes are correctly assigned between client and server
expect(await page.$eval('.red', e => getComputedStyle(e).color)).toBe('rgb(255, 0, 0)')
expect(await page.$eval('.blue', e => getComputedStyle(e).color)).toBe('rgb(0, 0, 255)')
expect(await page.locator('#client-side').textContent()).toContain('This should be rendered client-side')
await page.close()
})
})
describe('extends support', () => {
describe('layouts & pages', () => {
it('extends foo/layouts/default & foo/pages/index', async () => {
const html = await $fetch('/foo')
expect(html).toContain('Extended layout from foo')
expect(html).toContain('Extended page from foo')
})
it('extends [bar/layouts/override & bar/pages/override] over [foo/layouts/override & foo/pages/override]', async () => {
const html = await $fetch('/override')
expect(html).toContain('Extended layout from bar')
expect(html).toContain('Extended page from bar')
expect(html).toContain('This child page should not be overridden by bar')
})
})
describe('components', () => {
it('extends foo/components/ExtendsFoo', async () => {
const html = await $fetch('/foo')
expect(html).toContain('Extended component from foo')
})
it('extends bar/components/ExtendsOverride over foo/components/ExtendsOverride', async () => {
const html = await $fetch('/override')
expect(html).toContain('Extended component from bar')
})
})
describe('middlewares', () => {
it('works with layer aliases', async () => {
const html = await $fetch('/foo')
expect(html).toContain('from layer alias')
})
it('extends foo/middleware/foo', async () => {
const html = await $fetch('/foo')
expect(html).toContain('Middleware | foo: Injected by extended middleware from foo')
})
it('extends bar/middleware/override over foo/middleware/override', async () => {
const html = await $fetch('/override')
expect(html).toContain('Middleware | override: Injected by extended middleware from bar')
})
it('global middlewares sorting', async () => {
const html = await $fetch('/middleware/ordering')
expect(html).toContain('catchall at middleware')
})
})
describe('composables', () => {
it('extends foo/composables/foo', async () => {
const html = await $fetch('/foo')
expect(html).toContain('Composable | useExtendsFoo: foo')
})
it('allows overriding composables', async () => {
const html = await $fetch('/extends')
expect(html).toContain('test from project')
})
})
describe('plugins', () => {
it('extends foo/plugins/foo', async () => {
const html = await $fetch('/foo')
expect(html).toContain('Plugin | foo: String generated from foo plugin!')
})
it('respects plugin ordering within layers', async () => {
const html = await $fetch('/plugins/ordering')
expect(html).toContain('catchall at plugins')
})
})
describe('server', () => {
it('extends foo/server/api/foo', async () => {
expect(await $fetch('/api/foo')).toBe('foo')
})
it('extends foo/server/middleware/foo', async () => {
const { headers } = await fetch('/')
expect(headers.get('injected-header')).toEqual('foo')
})
})
describe('app', () => {
it('extends foo/app/router.options & bar/app/router.options', async () => {
const html: string = await $fetch('/')
const routerLinkClasses = html.match(/href="\/" class="([^"]*)"/)?.[1].split(' ')
expect(routerLinkClasses).toContain('foo-active-class')
expect(routerLinkClasses).toContain('bar-exact-active-class')
})
})
})
// Bug #7337
describe('deferred app suspense resolve', () => {
it.each(['/async-parent/child', '/internal-layout/async-parent/child'])('should wait for all suspense instance on initial hydration', async (path) => {
const { page, consoleLogs } = await renderPage(path)
// Wait for all pending micro ticks to be cleared in case hydration hasn't finished yet.
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
const hydrationLogs = consoleLogs.filter(log => log.text.includes('isHydrating'))
expect(hydrationLogs.length).toBe(3)
expect(hydrationLogs.every(log => log.text === 'isHydrating: true'))
await page.close()
})
it('should wait for suspense in parent layout', async () => {
const { page } = await renderPage('/hydration/layout')
await page.getByText('Tests whether hydration is properly resolved within an async layout').waitFor()
await page.close()
})
it('should fully hydrate even if there is a redirection on a page with `ssr: false`', async () => {
const { page } = await renderPage()
await page.goto(url('/hydration/spa-redirection/start'))
await page.getByText('fully hydrated and ready to go').waitFor()
await page.close()
})
})
describe('nested suspense', () => {
const navigations = ([
['/suspense/sync-1/async-1/', '/suspense/sync-2/async-1/'],
['/suspense/sync-1/sync-1/', '/suspense/sync-2/async-1/'],
['/suspense/async-1/async-1/', '/suspense/async-2/async-1/'],
['/suspense/async-1/sync-1/', '/suspense/async-2/async-1/'],
]).flatMap(([start, end]) => [
[start, end],
[start, end + '?layout=custom'],
[start + '?layout=custom', end],
])
it.each(navigations)('should navigate from %s to %s with no white flash', async (start, nav) => {
const { page, consoleLogs } = await renderPage(start)
const slug = nav.replace(/\?.*$/, '').replace(/[/-]+/g, '-')
await page.click(`[href^="${nav}"]`)
const text = await page.waitForFunction(slug => document.querySelector(`main:has(#child${slug})`)?.innerHTML, slug)
.then(r => r.evaluate(r => r))
// expect(text).toMatchInlineSnapshot()
// const parent = await page.waitForSelector(`#${slug}`, { state: 'attached' })
// const text = await parent.innerText()
expect(text).toContain('Async child: 2 - 1')
expect(text).toContain('parent: 2')
const first = start.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups!
const last = nav.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups!
expect(consoleLogs.map(l => l.text).filter(i => !i.includes('[vite]') && !i.includes(' is an experimental feature')).sort()).toEqual([
// [first load] from parent
`[${first.parentType}]`,
...first.parentType === 'async' ? ['[async] running async data'] : [],
// [first load] from child
`[${first.parentType}] [${first.childType}]`,
...first.childType === 'async' ? [`[${first.parentType}] [${first.parentNum}] [async] [${first.childNum}] running async data`] : [],
// [navigation] from parent
`[${last.parentType}]`,
...last.parentType === 'async' ? ['[async] running async data'] : [],
// [navigation] from child
`[${last.parentType}] [${last.childType}]`,
...last.childType === 'async' ? [`[${last.parentType}] [${last.parentNum}] [async] [${last.childNum}] running async data`] : [],
].sort())
await page.close()
})
const outwardNavigations = [
['/suspense/async-2/async-1/', '/suspense/async-1/'],
['/suspense/async-2/sync-1/', '/suspense/async-1/'],
]
it.each(outwardNavigations)('should navigate from %s to a parent %s with no white flash', async (start, nav) => {
const { page, consoleLogs } = await renderPage(start)
await page.waitForSelector(`main:has(#child${start.replace(/[/-]+/g, '-')})`)
const slug = start.replace(/[/-]+/g, '-')
await page.click(`[href^="${nav}"]`)
// wait until child selector disappears and grab HTML of parent
const text = await page.waitForFunction(slug => document.querySelector(`main:not(:has(#child${slug}))`)?.innerHTML, slug)
.then(r => r.evaluate(r => r))
expect(text).toContain('Async parent: 1')
const first = start.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups!
const last = nav.match(/\/suspense\/(?a?sync)-(?\d)\//)!.groups!
await new Promise(resolve => setTimeout(resolve, 50))
expect(consoleLogs.map(l => l.text).filter(i => !i.includes('[vite]') && !i.includes(' is an experimental feature')).sort()).toEqual([
// [first load] from parent
`[${first.parentType}]`,
...first.parentType === 'async' ? ['[async] running async data'] : [],
// [first load] from child
`[${first.parentType}] [${first.childType}]`,
...first.childType === 'async' ? [`[${first.parentType}] [${first.parentNum}] [async] [${first.childNum}] running async data`] : [],
// [navigation] from parent
`[${last.parentType}]`,
...last.parentType === 'async' ? ['[async] running async data'] : [],
].sort())
await page.close()
})
const inwardNavigations = [
['/suspense/async-2/', '/suspense/async-1/async-1/'],
['/suspense/async-2/', '/suspense/async-1/sync-1/'],
]
it.each(inwardNavigations)('should navigate from %s to a child %s with no white flash', async (start, nav) => {
const { page, consoleLogs } = await renderPage(start)
const slug = nav.replace(/[/-]+/g, '-')
await page.click(`[href^="${nav}"]`)
// wait until child selector appears and grab HTML of parent
const text = await page.waitForFunction(slug => document.querySelector(`main:has(#child${slug})`)?.innerHTML, slug)
.then(r => r.evaluate(r => r))
// const text = await parent.innerText()
expect(text).toContain('Async parent: 1')
const first = start.match(/\/suspense\/(?a?sync)-(?\d)\//)!.groups!
const last = nav.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups!
expect(consoleLogs.map(l => l.text).filter(i => !i.includes('[vite]') && !i.includes(' is an experimental feature')).sort()).toEqual([
// [first load] from parent
`[${first.parentType}]`,
...first.parentType === 'async' ? ['[async] running async data'] : [],
// [navigation] from parent
`[${last.parentType}]`,
...last.parentType === 'async' ? ['[async] running async data'] : [],
// [navigation] from child
`[${last.parentType}] [${last.childType}]`,
...last.childType === 'async' ? [`[${last.parentType}] [${last.parentNum}] [async] [${last.childNum}] running async data`] : [],
].sort())
await page.close()
})
})
// Bug #6592
describe('page key', () => {
it.each(['/fixed-keyed-child-parent', '/internal-layout/fixed-keyed-child-parent'])('should not cause run of setup if navigation not change page key and layout', async (path) => {
const { page, consoleLogs } = await renderPage(`${path}/0`)
await page.click(`[href="${path}/1"]`)
await page.waitForSelector('#page-1')
// Wait for all pending micro ticks to be cleared,
// so we are not resolved too early when there are repeated page loading
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
expect(consoleLogs.filter(l => l.text.includes('Child Setup')).length).toBe(1)
await page.close()
})
it.each(['/keyed-child-parent', '/internal-layout/keyed-child-parent'])('will cause run of setup if navigation changed page key', async (path) => {
const { page, consoleLogs } = await renderPage(`${path}/0`)
await page.click(`[href="${path}/1"]`)
await page.waitForSelector('#page-1')
// Wait for all pending micro ticks to be cleared,
// so we are not resolved too early when there are repeated page loading
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
expect(consoleLogs.filter(l => l.text.includes('Child Setup')).length).toBe(2)
await page.close()
})
})
describe('route provider', () => {
it('should preserve current route when navigation is suspended', async () => {
const { page } = await renderPage('/route-provider/foo')
await page.click('[href="/route-provider/bar"]')
expect(await page.getByTestId('foo').innerText()).toMatchInlineSnapshot('"foo: /route-provider/foo - /route-provider/foo"')
expect(await page.getByTestId('bar').innerText()).toMatchInlineSnapshot('"bar: /route-provider/bar - /route-provider/bar"')
await page.close()
})
})
// Bug #6592
describe('layout change not load page twice', () => {
const cases = {
'/with-layout': '/with-layout2',
'/internal-layout/with-layout': '/internal-layout/with-layout2',
}
it.each(Object.entries(cases))('should not cause run of page setup to repeat if layout changed', async (path1, path2) => {
const { page, consoleLogs } = await renderPage(path1)
await page.click(`[href="${path2}"]`)
await page.waitForSelector('#with-layout2')
// Wait for all pending micro ticks to be cleared,
// so we are not resolved too early when there are repeated page loading
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
expect(consoleLogs.filter(l => l.text.includes('Layout2 Page Setup')).length).toBe(1)
})
})
describe('layout switching', () => {
// #13309
it('does not cause TypeError: Cannot read properties of null', async () => {
const { page, consoleLogs, pageErrors } = await renderPage('/layout-switch/start')
await page.click('[href="/layout-switch/end"]')
await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/layout-switch/end')
expect(consoleLogs.map(i => i.text).filter(l => l.match(/error/i))).toMatchInlineSnapshot('[]')
expect(pageErrors).toMatchInlineSnapshot('[]')
await page.close()
})
})
describe('automatically keyed composables', () => {
it('should automatically generate keys', async () => {
const html = await $fetch('/keyed-composables')
expect(html).toContain('true')
expect(html).not.toContain('false')
})
it('should match server-generated keys', async () => {
await expectNoClientErrors('/keyed-composables')
})
it('should not automatically generate keys', async () => {
await expectNoClientErrors('/keyed-composables/local')
const html = await $fetch('/keyed-composables/local')
expect(html).toContain('true')
expect(html).not.toContain('false')
})
})
describe.runIf(isDev() && !isWebpack)('css links', () => {
it('should not inject links to CSS files that are inlined', async () => {
const html = await $fetch('/inline-only-css')
expect(html).toContain('--inline-only')
expect(html).not.toContain('inline-only.css')
expect(html).toContain('assets/plugin.css')
})
})
describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
const inlinedCSS = [
'{--plugin:"plugin"}', // CSS imported ambiently in JS/TS
'{--global:"global";', // global css from nuxt.config
'{--assets:"assets"}', //