Nuxt/packages/nuxt/test/pages.test.ts

706 lines
19 KiB
TypeScript
Raw Normal View History

import { describe, expect, it, vi } from 'vitest'
import type { NuxtPage } from 'nuxt/schema'
import { generateRoutesFromFiles, normalizeRoutes, pathToNitroGlob } from '../src/pages/utils'
import { generateRouteKey } from '../src/pages/runtime/utils'
describe('pages:generateRoutesFromFiles', () => {
const pagesDir = 'pages'
const layerDir = 'layer/pages'
const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const
vi.mock('knitwork', async (original) => {
return {
...(await original<typeof import('knitwork')>()),
'genArrayFromRaw': (val: any) => val,
'genSafeVariableName': (..._args: string[]) => {
return 'mock'
},
}
})
const tests: Array<{
description: string
files?: Array<{ path: string; template?: string; }>
output?: NuxtPage[]
normalized?: Record<string, any>[]
error?: string
}> = [
{
description: 'should generate correct routes for index pages',
files: [
{ path: `${pagesDir}/index.vue` },
{ path: `${pagesDir}/parent/index.vue` },
{ path: `${pagesDir}/parent/child/index.vue` }
],
output: [
{
name: 'index',
path: '/',
file: `${pagesDir}/index.vue`,
children: []
},
{
name: 'parent',
path: '/parent',
file: `${pagesDir}/parent/index.vue`,
children: []
},
{
name: 'parent-child',
path: '/parent/child',
file: `${pagesDir}/parent/child/index.vue`,
children: []
}
]
},
{
description: 'should generate correct routes for parent/child',
files: [
{ path: `${pagesDir}/parent.vue` },
{ path: `${pagesDir}/parent/child.vue` }
],
output: [
{
name: 'parent',
path: '/parent',
file: `${pagesDir}/parent.vue`,
children: [
{
name: 'parent-child',
path: 'child',
file: `${pagesDir}/parent/child.vue`,
children: []
}
]
}
]
},
{
description: 'should not generate colliding route names when hyphens are in file name',
files: [
{ path: `${pagesDir}/parent/[child].vue` },
{ path: `${pagesDir}/parent-[child].vue` }
],
output: [
{
name: 'parent-child',
path: '/parent/:child()',
file: `${pagesDir}/parent/[child].vue`,
children: []
},
{
name: 'parent-child',
path: '/parent-:child()',
file: `${pagesDir}/parent-[child].vue`,
children: []
}
]
},
{
description: 'should generate correct id for catchall (order 1)',
files: [
{ path: `${pagesDir}/[...stories].vue` },
{ path: `${pagesDir}/stories/[id].vue` }
],
output: [
{
name: 'stories',
path: '/:stories(.*)*',
file: `${pagesDir}/[...stories].vue`,
children: []
},
{
name: 'stories-id',
path: '/stories/:id()',
file: `${pagesDir}/stories/[id].vue`,
children: []
}
]
},
{
description: 'should generate correct id for catchall (order 2)',
files: [
{ path: `${pagesDir}/stories/[id].vue` },
{ path: `${pagesDir}/[...stories].vue` }
],
output: [
{
name: 'stories-id',
path: '/stories/:id()',
file: `${pagesDir}/stories/[id].vue`,
children: []
},
{
name: 'stories',
path: '/:stories(.*)*',
file: `${pagesDir}/[...stories].vue`,
children: []
}
]
},
{
description: 'should generate correct route for snake_case file',
files: [
{ path: `${pagesDir}/snake_case.vue` }
],
output: [
{
name: 'snake_case',
path: '/snake_case',
file: `${pagesDir}/snake_case.vue`,
children: []
}
]
},
{
description: 'should generate correct route for kebab-case file',
files: [{ path: `${pagesDir}/kebab-case.vue` }],
output: [
{
name: 'kebab-case',
path: '/kebab-case',
file: `${pagesDir}/kebab-case.vue`,
children: []
}
]
},
{
description: 'should generate correct dynamic routes',
files: [
{ path: `${pagesDir}/index.vue` },
{ path: `${pagesDir}/[slug].vue` },
{ path: `${pagesDir}/[[foo]]` },
{ path: `${pagesDir}/[[foo]]/index.vue` },
{ path: `${pagesDir}/optional/[[opt]].vue` },
{ path: `${pagesDir}/optional/prefix-[[opt]].vue` },
{ path: `${pagesDir}/optional/[[opt]]-postfix.vue` },
{ path: `${pagesDir}/optional/prefix-[[opt]]-postfix.vue` },
{ path: `${pagesDir}/[bar]/index.vue` },
{ path: `${pagesDir}/nonopt/[slug].vue` },
{ path: `${pagesDir}/opt/[[slug]].vue` },
{ path: `${pagesDir}/[[sub]]/route-[slug].vue` }
],
output: [
{
name: 'index',
path: '/',
file: `${pagesDir}/index.vue`,
children: []
},
{
children: [],
name: 'slug',
file: `${pagesDir}/[slug].vue`,
path: '/:slug()'
},
{
children: [
{
name: 'foo',
path: '',
file: `${pagesDir}/[[foo]]/index.vue`,
children: []
}
],
file: `${pagesDir}/[[foo]]`,
path: '/:foo?'
},
{
children: [],
path: '/optional/:opt?',
name: 'optional-opt',
file: `${pagesDir}/optional/[[opt]].vue`
},
{
children: [],
path: '/optional/prefix-:opt?',
name: 'optional-prefix-opt',
file: `${pagesDir}/optional/prefix-[[opt]].vue`
},
{
children: [],
path: '/optional/:opt?-postfix',
name: 'optional-opt-postfix',
file: `${pagesDir}/optional/[[opt]]-postfix.vue`
},
{
children: [],
path: '/optional/prefix-:opt?-postfix',
name: 'optional-prefix-opt-postfix',
file: `${pagesDir}/optional/prefix-[[opt]]-postfix.vue`
},
{
children: [],
name: 'bar',
file: `${pagesDir}/[bar]/index.vue`,
path: '/:bar()'
},
{
name: 'nonopt-slug',
path: '/nonopt/:slug()',
file: `${pagesDir}/nonopt/[slug].vue`,
children: []
},
{
name: 'opt-slug',
path: '/opt/:slug?',
file: `${pagesDir}/opt/[[slug]].vue`,
children: []
},
{
name: 'sub-route-slug',
path: '/:sub?/route-:slug()',
file: `${pagesDir}/[[sub]]/route-[slug].vue`,
children: []
}
]
},
{
description: 'should generate correct catch-all route',
files: [{ path: `${pagesDir}/[...slug].vue` }, { path: `${pagesDir}/index.vue` }],
output: [
{
name: 'slug',
path: '/:slug(.*)*',
file: `${pagesDir}/[...slug].vue`,
children: []
},
{
name: 'index',
path: '/',
file: `${pagesDir}/index.vue`,
children: []
}
]
},
{
description: 'should throw unfinished param error for dynamic route',
files: [{ path: `${pagesDir}/[slug.vue` }],
error: 'Unfinished param "slug"'
},
{
description: 'should throw empty param error for dynamic route',
files: [
{ path: `${pagesDir}/[].vue` }
],
error: 'Empty param'
},
{
description: 'should only allow "_" & "." as special character for dynamic route',
files: [
{ path: `${pagesDir}/[a1_1a].vue` },
{ path: `${pagesDir}/[b2.2b].vue` },
{ path: `${pagesDir}/[b2]_[2b].vue` },
{ path: `${pagesDir}/[[c3@3c]].vue` },
{ path: `${pagesDir}/[[d4-4d]].vue` }
],
output: [
{
name: 'a1_1a',
path: '/:a1_1a()',
file: `${pagesDir}/[a1_1a].vue`,
children: []
},
{
name: 'b2.2b',
path: '/:b2.2b()',
file: `${pagesDir}/[b2.2b].vue`,
children: []
},
{
name: 'b2_2b',
path: '/:b2()_:2b()',
file: `${pagesDir}/[b2]_[2b].vue`,
children: []
},
{
name: 'c33c',
path: '/:c33c?',
file: `${pagesDir}/[[c3@3c]].vue`,
children: []
},
{
name: 'd44d',
path: '/:d44d?',
file: `${pagesDir}/[[d4-4d]].vue`,
children: []
}
]
},
{
description: 'should properly override route name if definePageMeta name override is defined.',
files: [
{
path: `${pagesDir}/index.vue`,
template: `
<script setup lang="ts">
definePageMeta({
name: 'home'
})
</script>
`
}
],
output: [
{
name: 'home',
path: '/',
file: `${pagesDir}/index.vue`,
children: []
}
]
},
{
description: 'should allow pages with `:` in their path',
files: [
{ path: `${pagesDir}/test:name.vue` }
],
output: [
{
name: 'test:name',
path: '/test\\:name',
file: `${pagesDir}/test:name.vue`,
children: []
}
]
},
{
description: 'should not merge required param as a child of optional param',
files: [
{ path: `${pagesDir}/[[foo]].vue` },
{ path: `${pagesDir}/[foo].vue` }
],
output: [
{
name: 'foo',
path: '/:foo?',
file: `${pagesDir}/[[foo]].vue`,
children: [
]
},
{
name: 'foo',
path: '/:foo()',
file: `${pagesDir}/[foo].vue`,
children: []
}
]
},
{
description: 'should correctly merge nested routes',
files: [
{ path: `${pagesDir}/param.vue` },
{ path: `${layerDir}/param/index.vue` },
{ path: `${pagesDir}/param/index/index.vue` },
{ path: `${layerDir}/param/index/sibling.vue` },
{ path: `${pagesDir}/wrapper-expose/other.vue` },
{ path: `${layerDir}/wrapper-expose/other/index.vue` },
{ path: `${pagesDir}/wrapper-expose/other/sibling.vue` },
{ path: `${pagesDir}/param/sibling.vue` }
],
output: [
{
children: [
{
children: [
{
children: [],
file: `${pagesDir}/param/index/index.vue`,
name: 'param-index',
path: ''
},
{
children: [],
file: `${layerDir}/param/index/sibling.vue`,
name: 'param-index-sibling',
path: 'sibling'
}
],
file: `${layerDir}/param/index.vue`,
path: ''
},
{
children: [],
file: `${pagesDir}/param/sibling.vue`,
name: 'param-sibling',
path: 'sibling'
}
],
file: `${pagesDir}/param.vue`,
path: '/param'
},
{
children: [
{
children: [],
file: `${layerDir}/wrapper-expose/other/index.vue`,
name: 'wrapper-expose-other',
path: ''
},
{
children: [],
file: `${pagesDir}/wrapper-expose/other/sibling.vue`,
name: 'wrapper-expose-other-sibling',
path: 'sibling'
}
],
file: `${pagesDir}/wrapper-expose/other.vue`,
path: '/wrapper-expose/other'
}
]
},
{
description: 'should handle trailing slashes with index routes',
files: [
{ path: `${pagesDir}/index/index.vue` },
{ path: `${pagesDir}/index/index/all.vue` }
],
output: [
{
children: [
{
children: [],
file: `${pagesDir}/index/index/all.vue`,
name: 'index-index-all',
path: 'all'
}
],
file: `${pagesDir}/index/index.vue`,
name: 'index',
path: '/'
}
],
},
{
description: 'should use fallbacks when normalized with `overrideMeta: true`',
files: [
{
path: `${pagesDir}/index.vue`,
template: `
<script setup lang="ts">
const routeName = ref('home')
const routeAliases = ref(['sweet-home'])
definePageMeta({
name: routeName.value,
alias: routeAliases.value,
hello: 'world',
redirect: () => '/'
})
</script>
`
}
],
output: [
{
name: 'index',
path: '/',
file: `${pagesDir}/index.vue`,
meta: { [DYNAMIC_META_KEY]: new Set(['name', 'alias', 'redirect', 'meta']) },
children: []
}
]
},
{
description: 'should extract serializable values and override fallback when normalized with `overrideMeta: true`',
files: [
{
path: `${pagesDir}/index.vue`,
template: `
<script setup lang="ts">
definePageMeta({
name: 'home',
alias: ['sweet-home'],
redirect: '/',
hello: 'world'
})
</script>
`
}
],
output: [
{
name: 'home',
path: '/',
file: `${pagesDir}/index.vue`,
alias: ['sweet-home'],
redirect: '/',
children: [],
meta: { [DYNAMIC_META_KEY]: new Set(['meta']) },
}
]
},
{
description: 'route without file',
output: [
{
name: 'home',
path: '/',
alias: ['sweet-home'],
meta: { hello: 'world' },
}
]
},
]
const normalizedResults: Record<string, any> = {}
const normalizedOverrideMetaResults: Record<string, any> = {}
for (const test of tests) {
it(test.description, async () => {
let result
if (test.files) {
const vfs = Object.fromEntries(
test.files.map(file => [file.path, 'template' in file ? file.template : ''])
) as Record<string, string>
try {
result = await generateRoutesFromFiles(test.files.map(file => ({
absolutePath: file.path,
relativePath: file.path.replace(/^(pages|layer\/pages)\//, '')
})), { shouldExtractBuildMeta: true, vfs })
} catch (error: any) {
expect(error.message).toEqual(test.error)
}
} else {
result = test.output ?? []
}
if (result) {
expect(result).toEqual(test.output)
normalizedResults[test.description] = normalizeRoutes(result, new Set()).routes
normalizedOverrideMetaResults[test.description] = normalizeRoutes(result, new Set(), true).routes
}
})
}
it('should consistently normalize routes', async () => {
await expect(normalizedResults).toMatchFileSnapshot('./__snapshots__/pages-override-meta-disabled.test.ts.snap')
})
it('should consistently normalize routes when overriding meta', async () => {
await expect(normalizedOverrideMetaResults).toMatchFileSnapshot('./__snapshots__/pages-override-meta-enabled.test.ts.snap')
})
})
describe('pages:generateRouteKey', () => {
const defaultComponent = { type: {} }
const getRouteProps = (matchedRoute = {}) => ({
Component: defaultComponent,
route: {
meta: { key: 'route-meta-key' },
params: {
id: 'foo',
optional: 'bar',
array: ['a', 'b']
},
matched: [
{
components: { default: {} },
meta: { key: 'other-meta-key' }
},
{
components: { default: defaultComponent.type },
meta: { key: 'matched-meta-key' },
...matchedRoute
}
]
}
}) as any
const tests = [
{ description: 'should handle overrides', override: 'key', route: getRouteProps(), output: 'key' },
{ description: 'should handle overrides', override: (route: any) => route.meta.key as string, route: getRouteProps(), output: 'route-meta-key' },
{ description: 'should handle overrides', override: false as any, route: getRouteProps(), output: false },
{
description: 'should key dynamic routes without keys',
route: getRouteProps({
path: '/test/:id',
meta: {}
}),
output: '/test/foo'
},
{
description: 'should key dynamic routes without keys',
route: getRouteProps({
path: '/test/:id(\\d+)',
meta: {}
}),
output: '/test/foo'
},
{
description: 'should key dynamic routes with optional params',
route: getRouteProps({
path: '/test/:optional?',
meta: {}
}),
output: '/test/bar'
},
{
description: 'should key dynamic routes with optional params',
route: getRouteProps({
path: '/test/:optional(\\d+)?',
meta: {}
}),
output: '/test/bar'
},
{
description: 'should key dynamic routes with optional params',
route: getRouteProps({
path: '/test/:undefined(\\d+)?',
meta: {}
}),
output: '/test/'
},
{
description: 'should key dynamic routes with array params',
route: getRouteProps({
path: '/:array+',
meta: {}
}),
output: '/a,b'
},
{
description: 'should key dynamic routes with array params',
route: getRouteProps({
path: '/test/:array*',
meta: {}
}),
output: '/test/a,b'
},
{
description: 'should key dynamic routes with array params',
route: getRouteProps({
path: '/test/:other*',
meta: {}
}),
output: '/test/'
}
]
for (const test of tests) {
it(test.description, () => {
expect(generateRouteKey(test.route, test.override)).to.deep.equal(test.output)
})
}
})
const pathToNitroGlobTests = {
'/': '/',
'/:id': '/**',
'/:id()': '/**',
'/:id?': '/**',
'/some-:id?': '/**',
'/other/some-:id?': '/other/**',
'/other/some-:id()-more': '/other/**',
'/other/nested': '/other/nested'
}
describe('pages:pathToNitroGlob', () => {
it.each(Object.entries(pathToNitroGlobTests))('should convert %s to %s', (path, expected) => {
expect(pathToNitroGlob(path)).to.equal(expected)
})
})