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 type { ConsoleMessage } from 'playwright-core' import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer' import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils' const isWebpack = process.env.TEST_BUILDER === 'webpack' const isTestingAppManifest = process.env.TEST_MANIFEST !== 'manifest-off' 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 { script, attrs } = parseData(await $fetch('/route-rules/spa')) 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(' { it('should auto-register modules in ~/modules', async () => { const result = await $fetch('/auto-registered-module') expect(result).toEqual('handler added by auto-registered module') }) }) describe('pages', () => { it('render index', async () => { const html = await $fetch('/') // Snapshot // expect(html).toMatchInlineSnapshot() // should render text expect(html).toContain('Hello Nuxt 3!') // should inject runtime config expect(html).toContain('RuntimeConfig | testConfig: 123') expect(html).toContain('needsFallback:') // composables auto import expect(html).toContain('Composable | foo: auto imported from ~/composables/foo.ts') expect(html).toContain('Composable | bar: auto imported from ~/utils/useBar.ts') expect(html).toContain('Composable | template: auto imported from ~/composables/template.ts') expect(html).toContain('Composable | star: auto imported from ~/composables/nested/bar.ts via star export') // should import components expect(html).toContain('This is a custom component with a named export.') // should remove dev-only and replace with any fallback content expect(html).toContain(isDev() ? 'Some dev-only info' : 'Some prod-only info') // should apply attributes to client-only components expect(html).toContain('
') // should render server-only components expect(html.replace(/ nuxt-ssr-component-uid="[^"]*"/, '')).toContain('
server-only component
') // should register global components automatically expect(html).toContain('global component registered automatically') expect(html).toContain('global component via suffix') expect(html).toContain('This is a synchronously registered global component') await expectNoClientErrors('/') }) // TODO: support jsx with webpack it.runIf(!isWebpack)('supports jsx', async () => { const html = await $fetch('/jsx') // should import JSX/TSX components with custom elements expect(html).toContain('TSX component') expect(html).toContain('custom') expect(html).toContain('Sugar Counter 12 x 2 = 24') }) it('respects aliases in page metadata', async () => { const html = await $fetch('/some-alias') expect(html).toContain('Hello Nuxt 3!') }) it('respects redirects in page metadata', async () => { const { headers } = await fetch('/redirect', { redirect: 'manual' }) expect(headers.get('location')).toEqual('/') }) it('allows routes to be added dynamically', async () => { const html = await $fetch('/add-route-test') expect(html).toContain('Hello Nuxt 3!') }) it('includes page metadata from pages added in pages:extend hook', async () => { const res = await fetch('/page-extend') expect(res.headers.get('x-extend')).toEqual('added in pages:extend') }) it('validates routes', async () => { const { status, headers } = await fetch('/forbidden') expect(status).toEqual(404) expect(headers.get('Set-Cookie')).toBe('set-in-plugin=true; Path=/') const { page } = await renderPage('/navigate-to-forbidden') await page.getByText('should throw a 404 error').click() expect(await page.getByRole('heading').textContent()).toMatchInlineSnapshot('"Page Not Found: /forbidden"') expect(await page.getByTestId('path').textContent()).toMatchInlineSnapshot('" Path: /forbidden"') await gotoPath(page, '/navigate-to-forbidden') await page.getByText('should be caught by catchall').click() expect(await page.getByRole('heading').textContent()).toMatchInlineSnapshot('"[...slug].vue"') await page.close() }) it('returns 500 when there is an infinite redirect', async () => { const { status } = await fetch('/redirect-infinite', { redirect: 'manual' }) expect(status).toEqual(500) }) it('render catchall page', async () => { const res = await fetch('/not-found') expect(res.status).toEqual(200) const html = await res.text() // Snapshot // expect(html).toMatchInlineSnapshot() expect(html).toContain('[...slug].vue') expect(html).toContain('catchall at not-found') // Middleware still runs after validation: https://github.com/nuxt/nuxt/issues/15650 expect(html).toContain('Middleware ran: true') await expectNoClientErrors('/not-found') }) it('expect no loading indicator on middleware abortNavigation', async () => { const { page } = await renderPage('/') await page.locator('#middleware-abort-non-fatal').click() expect(await page.locator('#lodagin-indicator').all()).toHaveLength(0) await page.locator('#middleware-abort-non-fatal-error').click() expect(await page.locator('#lodagin-indicator').all()).toHaveLength(0) await page.close() }) it('should render correctly when loaded on a different path', async () => { const { page, pageErrors } = await renderPage('/proxy') expect(await page.innerText('body')).toContain('Composable | foo: auto imported from ~/composables/foo.ts') await page.close() expect(pageErrors).toEqual([]) }) it('preserves query', async () => { const html = await $fetch('/?test=true') // Snapshot // expect(html).toMatchInlineSnapshot() // should render text expect(html).toContain('Path: /?test=true') await expectNoClientErrors('/?test=true') }) it('/nested/[foo]/[bar].vue', async () => { const html = await $fetch('/nested/one/two') // Snapshot // expect(html).toMatchInlineSnapshot() expect(html).toContain('nested/[foo]/[bar].vue') expect(html).toContain('foo: one') expect(html).toContain('bar: two') }) it('/nested/[foo]/index.vue', async () => { const html = await $fetch('/nested/foobar') // TODO: should resolved to same entry // const html2 = await $fetch('/nested/foobar/index') // expect(html).toEqual(html2) // Snapshot // expect(html).toMatchInlineSnapshot() expect(html).toContain('nested/[foo]/index.vue') expect(html).toContain('foo: foobar') await expectNoClientErrors('/nested/foobar') }) it('/nested/[foo]/user-[group].vue', async () => { const html = await $fetch('/nested/foobar/user-admin') // Snapshot // expect(html).toMatchInlineSnapshot() expect(html).toContain('nested/[foo]/user-[group].vue') expect(html).toContain('foo: foobar') expect(html).toContain('group: admin') await expectNoClientErrors('/nested/foobar/user-admin') }) it('/parent', async () => { const html = await $fetch('/parent') expect(html).toContain('parent/index') await expectNoClientErrors('/parent') }) it('/another-parent', async () => { const html = await $fetch('/another-parent') expect(html).toContain('another-parent/index') await expectNoClientErrors('/another-parent') }) it('/client-server', async () => { // expect no hydration issues await expectNoClientErrors('/client-server') const page = await createPage('/client-server') await page.waitForLoadState('networkidle') const bodyHTML = await page.innerHTML('body') expect(await page.locator('.placeholder-to-ensure-no-override').all()).toHaveLength(5) expect(await page.locator('.server').all()).toHaveLength(0) expect(await page.locator('.client-fragment-server.client').all()).toHaveLength(2) expect(await page.locator('.client-fragment-server-fragment.client').all()).toHaveLength(2) expect(await page.locator('.client-server.client').all()).toHaveLength(1) expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1) expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1) expect(bodyHTML).not.toContain('hello') expect(bodyHTML).toContain('world') }) it('/client-only-components', async () => { const html = await $fetch('/client-only-components') // ensure fallbacks with classes and arbitrary attributes are rendered expect(html).toContain('
') expect(html).toContain('
') expect(html).toContain('
Fallback
') // ensure components are not rendered server-side expect(html).not.toContain('Should not be server rendered') const { page, pageErrors } = await renderPage('/client-only-components') const hiddenSelectors = [ '.string-stateful-should-be-hidden', '.client-script-should-be-hidden', '.string-stateful-script-should-be-hidden', '.no-state-hidden' ] const visibleSelectors = [ '.string-stateful', '.string-stateful-script', '.client-only-script', '.client-only-script-setup', '.no-state' ] // ensure directives are correctly applied await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isHidden())) .then(results => results.forEach(isHidden => expect(isHidden).toBeTruthy())) // ensure hidden components are still rendered await Promise.all(hiddenSelectors.map(selector => page.locator(selector).innerHTML())) .then(results => results.forEach(innerHTML => expect(innerHTML).not.toBe(''))) // ensure single root node components are rendered once on client (should not be empty) await Promise.all(visibleSelectors.map(selector => page.locator(selector).innerHTML())) .then(results => results.forEach(innerHTML => expect(innerHTML).not.toBe(''))) // issue #20061 expect(await page.$eval('.client-only-script-setup', e => getComputedStyle(e).backgroundColor)).toBe('rgb(255, 0, 0)') // ensure multi-root-node is correctly rendered expect(await page.locator('.multi-root-node-count').innerHTML()).toContain('0') expect(await page.locator('.multi-root-node-button').innerHTML()).toContain('add 1 to count') expect(await page.locator('.multi-root-node-script-count').innerHTML()).toContain('0') expect(await page.locator('.multi-root-node-script-button').innerHTML()).toContain('add 1 to count') // ensure components reactivity await page.locator('.multi-root-node-button').click() await page.locator('.multi-root-node-script-button').click() await page.locator('.client-only-script button').click() await page.locator('.client-only-script-setup button').click() expect(await page.locator('.multi-root-node-count').innerHTML()).toContain('1') expect(await page.locator('.multi-root-node-script-count').innerHTML()).toContain('1') expect(await page.locator('.client-only-script-setup button').innerHTML()).toContain('1') expect(await page.locator('.client-only-script button').innerHTML()).toContain('1') // ensure components ref is working and reactive await page.locator('button.test-ref-1').click() await page.locator('button.test-ref-2').click() await page.locator('button.test-ref-3').click() await page.locator('button.test-ref-4').click() expect(await page.locator('.client-only-script-setup button').innerHTML()).toContain('2') expect(await page.locator('.client-only-script button').innerHTML()).toContain('2') expect(await page.locator('.string-stateful-script').innerHTML()).toContain('1') expect(await page.locator('.string-stateful').innerHTML()).toContain('1') // ensure directives are reactive await page.locator('button#show-all').click() await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible())) .then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy())) expect(pageErrors).toEqual([]) await page.close() // don't expect any errors or warning on client-side navigation const { page: page2, consoleLogs: consoleLogs2 } = await renderPage('/') await page2.locator('#to-client-only-components').click() // force wait for a few ticks await page2.waitForTimeout(50) expect(consoleLogs2.some(log => log.type === 'error' || log.type === 'warning')).toBeFalsy() await page2.close() }) it('/wrapper-expose/layout', async () => { const { page, consoleLogs, pageErrors } = await renderPage('/wrapper-expose/layout') await page.locator('.log-foo').first().click() expect(pageErrors.at(-1)?.toString() || consoleLogs.at(-1)!.text).toContain('.logFoo is not a function') await page.locator('.log-hello').first().click() expect(consoleLogs.at(-1)!.text).toContain('world') await page.locator('.add-count').first().click() expect(await page.locator('.count').first().innerText()).toContain('1') // change layout await page.locator('.swap-layout').click() await page.waitForFunction(() => document.querySelector('.count')?.innerHTML.includes('0')) await page.locator('.log-foo').first().click() expect(consoleLogs.at(-1)!.text).toContain('bar') await page.locator('.log-hello').first().click() expect(pageErrors.at(-1)?.toString() || consoleLogs.at(-1)!.text).toContain('.logHello is not a function') await page.locator('.add-count').first().click() await page.waitForFunction(() => document.querySelector('.count')?.innerHTML.includes('1')) // change layout await page.locator('.swap-layout').click() await page.waitForFunction(() => document.querySelector('.count')?.innerHTML.includes('0')) await page.close() }) it('/client-only-explicit-import', async () => { const html = await $fetch('/client-only-explicit-import') // ensure fallbacks with classes and arbitrary attributes are rendered expect(html).toContain('
') expect(html).toContain('
') // ensure components are not rendered server-side expect(html).not.toContain('client only script') await expectNoClientErrors('/client-only-explicit-import') }) it('/wrapper-expose/page', async () => { const { page, pageErrors, consoleLogs } = await renderPage('/wrapper-expose/page') await page.locator('#log-foo').click() expect(consoleLogs.at(-1)?.text).toBe('bar') // change page await page.locator('#to-hello').click() await page.locator('#log-foo').click() expect(pageErrors.at(-1)?.toString() || consoleLogs.at(-1)!.text).toContain('.foo is not a function') await page.locator('#log-hello').click() expect(consoleLogs.at(-1)?.text).toBe('world') await page.close() }) it('client-fallback', async () => { const classes = [ 'clientfallback-non-stateful-setup', 'clientfallback-non-stateful', 'clientfallback-stateful-setup', 'clientfallback-stateful', 'clientfallback-async-setup' ] const html = await $fetch('/client-fallback') // ensure failed components are not rendered server-side expect(html).not.toContain('This breaks in server-side setup.') classes.forEach(c => expect(html).not.toContain(c)) // ensure not failed component not be rendered expect(html).not.toContain('Sugar Counter 12 x 0 = 0') // ensure NuxtClientFallback is being rendered with its fallback tag and attributes expect(html).toContain('this failed to render') // ensure Fallback slot is being rendered server side expect(html).toContain('Hello world !') // ensure not failed component are correctly rendered expect(html).not.toContain('

') expect(html).toContain('hi') // aysnc setup expect(html).toContain('Work with async setup') const { page, pageErrors } = await renderPage('/client-fallback') // ensure components reactivity once mounted await page.locator('#increment-count').click() expect(await page.locator('#sugar-counter').innerHTML()).toContain('Sugar Counter 12 x 1 = 12') // keep-fallback strategy expect(await page.locator('#keep-fallback').all()).toHaveLength(1) // #20833 expect(await page.locator('body').innerHTML()).not.toContain('Hello world !') expect(pageErrors).toEqual([]) await page.close() }) it('/legacy-async-data-fail', async () => { const response = await fetch('/legacy-async-data-fail').then(r => r.text()) expect(response).not.toContain('don\'t look at this') expect(response).toContain('OH NNNNNNOOOOOOOOOOO') }) }) describe('nuxt composables', () => { it('has useRequestURL()', async () => { const html = await $fetch('/url') expect(html).toContain('path: /url') }) it('sets cookies correctly', async () => { const res = await fetch('/cookies', { headers: { cookie: Object.entries({ 'browser-accessed-but-not-used': 'provided-by-browser', 'browser-accessed-with-default-value': 'provided-by-browser', 'browser-set': 'provided-by-browser', 'browser-set-to-null': 'provided-by-browser', 'browser-set-to-null-with-default': 'provided-by-browser' }).map(([key, value]) => `${key}=${value}`).join('; ') } }) const cookies = res.headers.get('set-cookie') expect(cookies).toMatchInlineSnapshot('"set-in-plugin=true; Path=/, set=set; Path=/, browser-set=set; Path=/, browser-set-to-null=; Max-Age=0; Path=/, browser-set-to-null-with-default=; Max-Age=0; Path=/"') }) }) describe('rich payloads', () => { it('correctly serializes and revivifies complex types', async () => { const html = await $fetch('/json-payload') for (const test of [ 'Date: true', 'Recursive objects: true', 'Shallow reactive: true', 'Shallow ref: true', 'Undefined ref: true', 'Reactive: true', 'Ref: true', 'Error: 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') await page.setViewportSize({ width: 1000, height: 1000 }) await page.waitForLoadState('networkidle') await page.locator('#big-page-2').scrollIntoViewIfNeeded() expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0) await page.locator('#big-page-2').click() await page.waitForURL(url => url.href.includes('/big-page-2')) await page.waitForTimeout(25) expect(await page.evaluate(() => window.scrollY)).toBe(0) await page.locator('#big-page-1').scrollIntoViewIfNeeded() expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0) await page.locator('#big-page-1').click() await page.waitForURL(url => url.href.includes('/big-page-1')) await page.waitForTimeout(25) expect(await page.evaluate(() => window.scrollY)).toBe(0) await page.close() }) it('expect scroll to top on nested pages', async () => { // #20523 const page = await createPage('/nested/foo/test') await page.setViewportSize({ width: 1000, height: 1000 }) await page.waitForLoadState('networkidle') await page.locator('#user-test').scrollIntoViewIfNeeded() expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0) await page.locator('#user-test').click() await page.waitForURL(url => url.href.includes('/nested/foo/user-test')) await page.waitForTimeout(25) expect(await page.evaluate(() => window.scrollY)).toBe(0) await page.locator('#test').scrollIntoViewIfNeeded() expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0) await page.locator('#test').click() await page.waitForURL(url => url.href.includes('/nested/foo/test')) await page.waitForTimeout(25) expect(await page.evaluate(() => window.scrollY)).toBe(0) 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('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('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('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') }) }) 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('/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) // @ts-expect-error TODO: fix upstream in playwright - types for evaluate are broken .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) // @ts-expect-error TODO: fix upstream in playwright - types for evaluate are broken .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) // @ts-expect-error TODO: fix upstream in playwright - types for evaluate are broken .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.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"}', //