Skip to content

Commit affebb4

Browse files
authored
test(nuxt): Add unit tests for catch-all routes (#16891)
Test follow-up for this PR: #16843 Re-organizes the unit tests a bit to be less repetitive with default data that is aligned to real-world examples. --- Adds unit tests for cases mentioned [here](#16843 (comment)): - differentiate dynamic vs static route on the same path (`users/:id` vs `users/settings`) - cases for catch-all routes
1 parent 7a8840d commit affebb4

File tree

3 files changed

+277
-316
lines changed

3 files changed

+277
-316
lines changed

packages/nuxt/src/runtime/utils/route-extraction.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const extractionResultCache = new Map<string, null | { parametrizedRoute: string
1616
* This is a Set of module paths that were used when loading one specific page.
1717
* Example: `Set(['app.vue', 'components/Button.vue', 'pages/user/[userId].vue'])`
1818
*
19-
* @param currentUrl - The requested URL string
19+
* @param requestedUrl - The requested URL string
2020
* Example: `/user/123`
2121
*
2222
* @param buildTimePagesData
@@ -25,10 +25,10 @@ const extractionResultCache = new Map<string, null | { parametrizedRoute: string
2525
*/
2626
export function extractParametrizedRouteFromContext(
2727
ssrContextModules?: NuxtSSRContext['modules'],
28-
currentUrl?: NuxtSSRContext['url'],
28+
requestedUrl?: NuxtSSRContext['url'],
2929
buildTimePagesData: NuxtPageSubset[] = [],
3030
): null | { parametrizedRoute: string } {
31-
if (!ssrContextModules || !currentUrl) {
31+
if (!ssrContextModules || !requestedUrl) {
3232
return null;
3333
}
3434

@@ -39,11 +39,11 @@ export function extractParametrizedRouteFromContext(
3939
const cacheKey = Array.from(ssrContextModules).sort().join('|');
4040
const cachedResult = extractionResultCache.get(cacheKey);
4141
if (cachedResult !== undefined) {
42-
logger.log('Found cached result for parametrized route:', currentUrl);
42+
logger.log('Found cached result for parametrized route:', requestedUrl);
4343
return cachedResult;
4444
}
4545

46-
logger.log('No parametrized route found in cache lookup. Extracting parametrized route for:', currentUrl);
46+
logger.log('No parametrized route found in cache lookup. Extracting parametrized route for:', requestedUrl);
4747

4848
const modulesArray = Array.from(ssrContextModules);
4949

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { NuxtPageSubset } from '../../../src/runtime/utils/route-extraction';
3+
import { extractParametrizedRouteFromContext } from '../../../src/runtime/utils/route-extraction';
4+
5+
/** Creates a mock NuxtPage object with all existing pages. Nuxt provides this during the build time in the "pages:extend" hook.
6+
* The content is inspired by a real-world example. */
7+
const createMockPagesData = (
8+
overrides: NuxtPageSubset[] = [],
9+
addDefaultPageData: boolean = true,
10+
): NuxtPageSubset[] => {
11+
const defaultBase = [
12+
// Basic routes
13+
{ path: '/', file: '/private/folders/application/pages/index.vue' },
14+
{ path: '/simple-page', file: '/private/folders/application/pages/simple-page.vue' },
15+
{ path: '/a/nested/simple-page', file: '/private/folders/application/pages/a/nested/simple-page.vue' },
16+
// Dynamic routes (directory and file)
17+
{ path: '/user/:userId()', file: '/private/folders/application/pages/user/[userId].vue' },
18+
{ path: '/group-:name()/:id()', file: '/private/folders/application/pages/group-[name]/[id].vue' },
19+
// Catch-all routes
20+
{ path: '/catch-all/:path(.*)*', file: '/private/folders/application/pages/catch-all/[...path].vue' },
21+
];
22+
23+
return [...(addDefaultPageData ? defaultBase : []), ...overrides];
24+
};
25+
26+
// The base of modules when loading a specific page during runtime (inspired by real-world examples).
27+
const defaultSSRContextModules = new Set([
28+
'node_modules/nuxt/dist/app/components/nuxt-root.vue',
29+
'app.vue',
30+
'components/Button.vue',
31+
// ...the specific requested page is added in the test (e.g. 'pages/user/[userId].vue')
32+
]);
33+
34+
describe('extractParametrizedRouteFromContext', () => {
35+
describe('edge cases', () => {
36+
it('should return null when ssrContextModules is null', () => {
37+
const result = extractParametrizedRouteFromContext(null as any, '/test', []);
38+
expect(result).toBe(null);
39+
});
40+
41+
it('should return null when currentUrl is null', () => {
42+
const result = extractParametrizedRouteFromContext(defaultSSRContextModules, null as any, []);
43+
expect(result).toBe(null);
44+
});
45+
46+
it('should return null when currentUrl is undefined', () => {
47+
const result = extractParametrizedRouteFromContext(defaultSSRContextModules, undefined as any, []);
48+
expect(result).toBe(null);
49+
});
50+
51+
it('should return null when buildTimePagesData is empty', () => {
52+
const result = extractParametrizedRouteFromContext(defaultSSRContextModules, '/test', []);
53+
expect(result).toEqual(null);
54+
});
55+
56+
it('should return null when buildTimePagesData has no valid files', () => {
57+
const buildTimePagesData = createMockPagesData([
58+
{ path: '/test', file: undefined },
59+
{ path: '/about', file: null as any },
60+
]);
61+
62+
const result = extractParametrizedRouteFromContext(defaultSSRContextModules, '/test', buildTimePagesData);
63+
expect(result).toEqual(null);
64+
});
65+
});
66+
67+
describe('basic route matching', () => {
68+
it.each([
69+
{
70+
description: 'basic page route',
71+
modules: new Set([...defaultSSRContextModules, 'pages/simple-page.vue']),
72+
requestedUrl: '/simple-page',
73+
buildTimePagesData: createMockPagesData(),
74+
expected: { parametrizedRoute: '/simple-page' },
75+
},
76+
{
77+
description: 'nested route',
78+
modules: new Set([...defaultSSRContextModules, 'pages/a/nested/simple-page.vue']),
79+
requestedUrl: '/a/nested/simple-page',
80+
buildTimePagesData: createMockPagesData(),
81+
expected: { parametrizedRoute: '/a/nested/simple-page' },
82+
},
83+
{
84+
description: 'dynamic route with brackets in file name',
85+
modules: new Set([...defaultSSRContextModules, 'pages/user/[userId].vue']),
86+
requestedUrl: '/user/123',
87+
buildTimePagesData: createMockPagesData(),
88+
expected: { parametrizedRoute: '/user/:userId()' },
89+
},
90+
{
91+
description: 'dynamic route with brackets in directory and file name',
92+
modules: new Set([...defaultSSRContextModules, 'pages/group-[name]/[id].vue']),
93+
requestedUrl: '/group-sentry/123',
94+
buildTimePagesData: createMockPagesData(),
95+
expected: { parametrizedRoute: '/group-:name()/:id()' },
96+
},
97+
{
98+
description: 'catch all route (simple)',
99+
modules: new Set([...defaultSSRContextModules, 'pages/catch-all/[...path].vue']),
100+
requestedUrl: '/catch-all/whatever',
101+
buildTimePagesData: createMockPagesData(),
102+
expected: { parametrizedRoute: '/catch-all/:path(.*)*' },
103+
},
104+
{
105+
description: 'catch all route (nested)',
106+
modules: new Set([...defaultSSRContextModules, 'pages/catch-all/[...path].vue']),
107+
requestedUrl: '/catch-all/whatever/you/want',
108+
buildTimePagesData: createMockPagesData(),
109+
expected: { parametrizedRoute: '/catch-all/:path(.*)*' },
110+
},
111+
])('should match $description', ({ modules, requestedUrl, buildTimePagesData, expected }) => {
112+
const result = extractParametrizedRouteFromContext(modules, requestedUrl, buildTimePagesData);
113+
expect(result).toEqual(expected);
114+
});
115+
});
116+
117+
describe('different folder structures (no pages directory)', () => {
118+
it.each([
119+
{
120+
description: 'views folder instead of pages',
121+
folderName: 'views',
122+
modules: new Set([...defaultSSRContextModules, 'views/dashboard.vue']),
123+
routeFile: '/app/views/dashboard.vue',
124+
routePath: '/dashboard',
125+
},
126+
{
127+
description: 'routes folder',
128+
folderName: 'routes',
129+
modules: new Set([...defaultSSRContextModules, 'routes/api/users.vue']),
130+
routeFile: '/app/routes/api/users.vue',
131+
routePath: '/api/users',
132+
},
133+
{
134+
description: 'src/pages folder structure',
135+
folderName: 'src/pages',
136+
modules: new Set([...defaultSSRContextModules, 'src/pages/contact.vue']),
137+
routeFile: '/app/src/pages/contact.vue',
138+
routePath: '/contact',
139+
},
140+
])('should work with $description', ({ modules, routeFile, routePath }) => {
141+
const buildTimePagesData = createMockPagesData([{ path: routePath, file: routeFile }]);
142+
143+
const result = extractParametrizedRouteFromContext(modules, routePath, buildTimePagesData);
144+
expect(result).toEqual({ parametrizedRoute: routePath });
145+
});
146+
});
147+
148+
describe('multiple routes matching', () => {
149+
it('should return correct route app has a dynamic route and a static route that share the same path', () => {
150+
const modules = new Set([...defaultSSRContextModules, 'pages/user/settings.vue']);
151+
152+
const buildTimePagesData = createMockPagesData(
153+
[
154+
{ path: '/user/settings', file: '/private/folders/application/pages/user/settings.vue' },
155+
{ path: '/user/:userId()', file: '/private/folders/application/pages/user/[userId].vue' },
156+
],
157+
false,
158+
);
159+
160+
const result = extractParametrizedRouteFromContext(modules, '/user/settings', buildTimePagesData);
161+
expect(result).toEqual({ parametrizedRoute: '/user/settings' });
162+
});
163+
164+
it('should return correct route app has a dynamic route and a static route that share the same path (reverse)', () => {
165+
const modules = new Set([...defaultSSRContextModules, 'pages/user/settings.vue']);
166+
167+
const buildTimePagesData = createMockPagesData([
168+
{ path: '/user/:userId()', file: '/private/folders/application/pages/user/[userId].vue' },
169+
{ path: '/user/settings', file: '/private/folders/application/pages/user/settings.vue' },
170+
]);
171+
172+
const result = extractParametrizedRouteFromContext(modules, '/user/settings', buildTimePagesData);
173+
expect(result).toEqual({ parametrizedRoute: '/user/settings' });
174+
});
175+
176+
it('should return null for non-route files', () => {
177+
const modules = new Set(['app.vue', 'components/Header.vue', 'components/Footer.vue', 'layouts/default.vue']);
178+
179+
// /simple-page is not in the module Set
180+
const result = extractParametrizedRouteFromContext(modules, '/simple-page', createMockPagesData());
181+
expect(result).toEqual(null);
182+
});
183+
});
184+
185+
describe('complex path scenarios', () => {
186+
it.each([
187+
{
188+
description: 'absolute path with multiple directories',
189+
file: 'folders/XYZ/some-folder/app/pages/client-error.vue',
190+
module: 'pages/client-error.vue',
191+
path: '/client-error',
192+
requestedUrl: '/client-error',
193+
},
194+
{
195+
description: 'absolute path with dynamic route',
196+
file: '/private/var/folders/XYZ/some-folder/app/pages/test-param/user/[userId].vue',
197+
module: 'pages/test-param/user/[userId].vue',
198+
path: '/test-param/user/:userId()',
199+
requestedUrl: '/test-param/user/123',
200+
},
201+
{
202+
description: 'Windows-style path separators',
203+
file: 'C:\\app\\pages\\dashboard\\index.vue',
204+
module: 'pages/dashboard/index.vue',
205+
path: '/dashboard',
206+
requestedUrl: '/dashboard',
207+
},
208+
])('should handle $description', ({ file, module, path, requestedUrl }) => {
209+
const modules = new Set([...defaultSSRContextModules, module]);
210+
const buildTimePagesData = createMockPagesData([{ path, file }]);
211+
212+
const result = extractParametrizedRouteFromContext(modules, requestedUrl, buildTimePagesData);
213+
expect(result).toEqual({ parametrizedRoute: path });
214+
});
215+
});
216+
217+
describe('no matches', () => {
218+
it('should return null when no route data matches any module', () => {
219+
const modules = new Set([...defaultSSRContextModules, 'pages/non-existent.vue']);
220+
const buildTimePagesData = createMockPagesData();
221+
222+
const result = extractParametrizedRouteFromContext(modules, '/non-existent', buildTimePagesData);
223+
expect(result).toEqual(null);
224+
});
225+
226+
it('should exclude root-level modules correctly', () => {
227+
const modules = new Set([...defaultSSRContextModules, 'error.vue', 'middleware.js']);
228+
const buildTimePagesData = createMockPagesData([{ path: '/', file: '/app/app.vue' }]);
229+
230+
const result = extractParametrizedRouteFromContext(modules, '/', buildTimePagesData);
231+
expect(result).toEqual(null);
232+
});
233+
});
234+
235+
describe('malformed data handling', () => {
236+
it('should handle modules with empty strings', () => {
237+
const modules = new Set([...defaultSSRContextModules, '', 'pages/test.vue', ' ']);
238+
const buildTimePagesData = createMockPagesData([{ path: '/test', file: '/app/pages/test.vue' }]);
239+
240+
const result = extractParametrizedRouteFromContext(modules, '/test', buildTimePagesData);
241+
expect(result).toEqual({ parametrizedRoute: '/test' });
242+
});
243+
});
244+
245+
describe('edge case file patterns', () => {
246+
it('should handle file paths that do not follow standard patterns (module not included in pages data)', () => {
247+
const modules = new Set(['custom/special-route.vue']);
248+
const buildTimePagesData = createMockPagesData([
249+
{
250+
path: '/special',
251+
file: '/unusual/path/structure/custom/special-route.vue',
252+
},
253+
]);
254+
255+
const result = extractParametrizedRouteFromContext(modules, '/special', buildTimePagesData);
256+
expect(result).toEqual({ parametrizedRoute: '/special' });
257+
});
258+
259+
it('should not match when file patterns are completely different', () => {
260+
const modules = new Set(['pages/user.vue']);
261+
const buildTimePagesData = createMockPagesData([
262+
{
263+
path: '/admin',
264+
file: '/app/admin/dashboard.vue', // Different structure
265+
},
266+
]);
267+
268+
const result = extractParametrizedRouteFromContext(modules, '/user', buildTimePagesData);
269+
expect(result).toEqual(null);
270+
});
271+
});
272+
});

0 commit comments

Comments
 (0)