diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts index 272c98efff16..df8032551228 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(buildAssetFolderOccurred).toBe(false); - // todo: url not yet parametrized - expect(transactionEvent.transaction).toBe('GET /test-param/1234'); + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts index d3b7415e7678..825babc01780 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => { const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); - expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -47,8 +48,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param()`, // parametrized + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => { expect(ssrTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.server.test.ts index e517602f4ade..260af81f11a4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.server.test.ts @@ -41,6 +41,6 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(buildAssetFolderOccurred).toBe(false); - // todo: url not yet parametrized + // Parametrization does not work in Nuxt 3.7 yet (only in newer versions) expect(transactionEvent.transaction).toBe('GET /test-param/1234'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts index a23b1bb8f35a..321d61a8753a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => { const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); - expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + // Parametrization does not work in Nuxt 3.7 yet (only in newer versions) expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -47,7 +48,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize + transaction: `GET /test-param/${PARAM}`, // Parametrization does not work in Nuxt 3.7 yet (only in newer versions) transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -121,7 +122,7 @@ test.describe('distributed tracing', () => { expect(ssrTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) + transaction: `GET /test-param/user/${PARAM}`, // Parametrization does not work in Nuxt 3.7 yet (only in newer versions) transaction_info: { source: 'url' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts index cbb0b7fceef7..cc4b6cd36567 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(buildAssetFolderOccurred).toBe(false); - // todo: url not yet parametrized - expect(transactionEvent.transaction).toBe('GET /test-param/1234'); + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts index 62f8f9ab51e0..b7c49676cc4f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => { const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); - expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -47,8 +48,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param()`, // parametrized + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => { expect(ssrTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts index 2785a47802e8..f1df13a71ab3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(buildAssetFolderOccurred).toBe(false); - // todo: url not yet parametrized - expect(transactionEvent.transaction).toBe('GET /test-param/1234'); + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts index de19d6d739f9..fbd8e13d0d25 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => { const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); - expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -47,8 +48,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param()`, // parametrized + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => { expect(ssrTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index a84bd139a2de..b6453b5a11cd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(buildAssetFolderOccurred).toBe(false); - // todo: url not yet parametrized - expect(transactionEvent.transaction).toBe('GET /test-param/1234'); + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 3448851dd299..4839cc87dd57 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => { const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); - expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -47,8 +48,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param()`, // parametrized route + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => { expect(ssrTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 5392774d330d..0e6d92636246 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -1,4 +1,11 @@ -import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit'; +import { + addPlugin, + addPluginTemplate, + addServerPlugin, + addTemplate, + createResolver, + defineNuxtModule, +} from '@nuxt/kit'; import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; @@ -70,6 +77,11 @@ export default defineNuxtModule({ if (serverConfigFile) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); + + addPlugin({ + src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), + mode: 'server', + }); } if (clientConfigFile || serverConfigFile) { @@ -78,6 +90,26 @@ export default defineNuxtModule({ addOTelCommonJSImportAlias(nuxt); + const pagesDataTemplate = addTemplate({ + filename: 'sentry--nuxt-pages-data.mjs', + // Initial empty array (later filled in pages:extend hook) + // Template needs to be created in the root-level of the module to work + getContents: () => 'export default [];', + }); + + nuxt.hooks.hook('pages:extend', pages => { + pagesDataTemplate.getContents = () => { + const pagesSubset = pages + .map(page => ({ file: page.file, path: page.path })) + .filter(page => { + // Check for dynamic parameter (e.g., :userId or [userId]) + return page.path.includes(':') || page?.file?.includes('['); + }); + + return `export default ${JSON.stringify(pagesSubset, null, 2)};`; + }; + }); + nuxt.hooks.hook('nitro:init', nitro => { if (serverConfigFile?.includes('.server.config')) { if (nitro.options.dev) { diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts index 28854a320bc0..d43f3bf34901 100644 --- a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -1,4 +1,4 @@ -import { getActiveSpan, getCurrentScope, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { H3Event } from 'h3'; /** @@ -15,16 +15,10 @@ export function updateRouteBeforeResponse(event: H3Event): void { // Example: Matched route is "/users/:id" and the event's path is "/users/123", if (matchedRoutePath && matchedRoutePath !== event._path) { if (matchedRoutePath === '/**') { - // todo: support parametrized SSR pageload spans // If page is server-side rendered, the whole path gets transformed to `/**` (Example : `/users/123` becomes `/**` instead of `/users/:id`). - return; // Skip if the matched route is a catch-all route. + return; // Skip if the matched route is a catch-all route (handled in `route-detector.server.ts`) } - const method = event._method || 'GET'; - - const parametrizedTransactionName = `${method.toUpperCase()} ${matchedRoutePath}`; - getCurrentScope().setTransactionName(parametrizedTransactionName); - const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined if (!activeSpan) { return; @@ -52,6 +46,6 @@ export function updateRouteBeforeResponse(event: H3Event): void { }); } - logger.log(`Updated transaction name for parametrized route: ${parametrizedTransactionName}`); + logger.log(`Updated transaction name for parametrized route: ${matchedRoutePath}`); } } diff --git a/packages/nuxt/src/runtime/plugins/route-detector.server.ts b/packages/nuxt/src/runtime/plugins/route-detector.server.ts new file mode 100644 index 000000000000..9b6a172e1da2 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/route-detector.server.ts @@ -0,0 +1,49 @@ +import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { defineNuxtPlugin } from 'nuxt/app'; +import type { NuxtPageSubset } from '../utils/route-extraction'; +import { extractParametrizedRouteFromContext } from '../utils/route-extraction'; + +export default defineNuxtPlugin(nuxtApp => { + nuxtApp.hooks.hook('app:rendered', async renderContext => { + let buildTimePagesData: NuxtPageSubset[]; + try { + // This is a common Nuxt pattern to import build-time generated data: https://nuxt.com/docs/4.x/api/kit/templates#creating-a-virtual-file-for-runtime-plugin + // @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts) + const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs'); + buildTimePagesData = importedPagesData || []; + logger.log('Imported build-time pages data:', buildTimePagesData); + } catch (error) { + buildTimePagesData = []; + logger.warn('Failed to import build-time pages data:', error); + } + + const ssrContext = renderContext.ssrContext; + + const routeInfo = extractParametrizedRouteFromContext( + ssrContext?.modules, + ssrContext?.url || ssrContext?.event._path, + buildTimePagesData, + ); + + if (routeInfo === null) { + return; + } + + const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined + + if (activeSpan && routeInfo.parametrizedRoute) { + const rootSpan = getRootSpan(activeSpan); + + if (!rootSpan) { + return; + } + + logger.log('Matched parametrized server route:', routeInfo.parametrizedRoute); + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': routeInfo.parametrizedRoute, + }); + } + }); +}); diff --git a/packages/nuxt/src/runtime/utils/route-extraction.ts b/packages/nuxt/src/runtime/utils/route-extraction.ts new file mode 100644 index 000000000000..a001e3306361 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/route-extraction.ts @@ -0,0 +1,77 @@ +import { logger } from '@sentry/core'; +import type { NuxtSSRContext } from 'nuxt/app'; +import type { NuxtPage } from 'nuxt/schema'; + +export type NuxtPageSubset = { path: NuxtPage['path']; file: NuxtPage['file'] }; + +const extractionResultCache = new Map(); + +/** + * Extracts route information from the SSR context modules and URL. + * + * The function matches the requested URL against the build-time pages data. The build-time pages data + * contains the routes that were generated during the build process, which allows us to set the parametrized route. + * + * @param ssrContextModules - The modules from the SSR context. + * This is a Set of module paths that were used when loading one specific page. + * Example: `Set(['app.vue', 'components/Button.vue', 'pages/user/[userId].vue'])` + * + * @param currentUrl - The requested URL string + * Example: `/user/123` + * + * @param buildTimePagesData + * An array of NuxtPage objects representing the build-time pages data. + * Example: [{ file: '/a/file/pages/some/path', path: '/some/path' }, { file: '/a/file/pages/user/[userId].vue', path: '/user/:userId()' }] + */ +export function extractParametrizedRouteFromContext( + ssrContextModules?: NuxtSSRContext['modules'], + currentUrl?: NuxtSSRContext['url'], + buildTimePagesData: NuxtPageSubset[] = [], +): null | { parametrizedRoute: string } { + if (!ssrContextModules || !currentUrl) { + return null; + } + + if (buildTimePagesData.length === 0) { + return null; + } + + const cacheKey = Array.from(ssrContextModules).sort().join('|'); + const cachedResult = extractionResultCache.get(cacheKey); + if (cachedResult !== undefined) { + logger.log('Found cached result for parametrized route:', currentUrl); + return cachedResult; + } + + logger.log('No parametrized route found in cache lookup. Extracting parametrized route for:', currentUrl); + + const modulesArray = Array.from(ssrContextModules); + + const modulePagePaths = modulesArray.map(module => { + const filePathParts = module.split('/'); + + // Exclude root-level files (e.g., 'app.vue') + if (filePathParts.length < 2) return null; + + const pagesFolder = filePathParts[0]; + const pageRelativePath = filePathParts.slice(1).join('/'); + return `/${pagesFolder}/${pageRelativePath}`; + }); + + for (const routeData of buildTimePagesData) { + if (routeData.file && routeData.path) { + // Handle Windows paths + const normalizedFile = routeData.file.replace(/\\/g, '/'); + + // Check if any module of the requested page ends with the same folder/relative path structure as the parametrized filePath from build time. + if (modulePagePaths.some(filePath => filePath && normalizedFile.endsWith(filePath))) { + const result = { parametrizedRoute: routeData.path }; + extractionResultCache.set(cacheKey, result); + return result; + } + } + } + + extractionResultCache.set(cacheKey, null); + return null; +} diff --git a/packages/nuxt/test/runtime/utils/route-extraction.ts b/packages/nuxt/test/runtime/utils/route-extraction.ts new file mode 100644 index 000000000000..0b2c9ffe0b2c --- /dev/null +++ b/packages/nuxt/test/runtime/utils/route-extraction.ts @@ -0,0 +1,311 @@ +import type { NuxtPage } from 'nuxt/schema'; +import { describe, expect, it } from 'vitest'; +import { extractParametrizedRouteFromContext } from '../../../src/runtime/utils/route-extraction'; + +describe('extractParametrizedRouteFromContext', () => { + const createMockRouteData = (overrides: Partial = {}): NuxtPage => ({ + name: '', + path: '', + file: '', + children: [], + ...overrides, + }); + + describe('edge cases', () => { + it('should return null when ssrContextModules is null', () => { + const result = extractParametrizedRouteFromContext(null as any, '/test', []); + expect(result).toBe(null); + }); + + it('should return null when currentUrl is null', () => { + const modules = new Set(['pages/test.vue']); + const result = extractParametrizedRouteFromContext(modules, null as any, []); + expect(result).toBe(null); + }); + + it('should return null when currentUrl is undefined', () => { + const modules = new Set(['pages/test.vue']); + const result = extractParametrizedRouteFromContext(modules, undefined as any, []); + expect(result).toBe(null); + }); + + it('should return null when buildTimePagesData is empty', () => { + const modules = new Set(['pages/test.vue']); + const result = extractParametrizedRouteFromContext(modules, '/test', []); + expect(result).toEqual(null); + }); + + it('should return null when buildTimePagesData has no valid files', () => { + const modules = new Set(['pages/test.vue']); + const buildTimePagesData = [ + createMockRouteData({ name: 'test', path: '/test', file: undefined }), + createMockRouteData({ name: 'about', path: '/about', file: null as any }), + ]; + const result = extractParametrizedRouteFromContext(modules, '/test', buildTimePagesData); + expect(result).toEqual(null); + }); + }); + + describe('basic route matching', () => { + it.each([ + { + description: 'basic page route', + modules: new Set(['app.vue', 'pages/home.vue', 'components/Button.vue']), + currentUrl: '/home', + buildTimePagesData: [ + createMockRouteData({ + name: 'home', + path: '/home', + file: '/app/pages/home.vue', + }), + ], + expected: { + parametrizedRoute: '/home', + }, + }, + { + description: 'nested route', + modules: new Set(['app.vue', 'pages/user/profile.vue']), + currentUrl: '/user/profile', + buildTimePagesData: [ + createMockRouteData({ + name: 'user-profile', + path: '/user/profile', + file: '/app/pages/user/profile.vue', + }), + ], + expected: { parametrizedRoute: '/user/profile' }, + }, + { + description: 'dynamic route with brackets', + modules: new Set(['app.vue', 'pages/test-param/[param].vue']), + currentUrl: '/test-param/123', + buildTimePagesData: [ + createMockRouteData({ + name: 'test-param-param', + path: '/test-param/:param()', + file: '/app/pages/test-param/[param].vue', + }), + ], + expected: { parametrizedRoute: '/test-param/:param()' }, + }, + { + description: 'nested dynamic route', + modules: new Set(['app.vue', 'pages/test-param/user/[userId].vue']), + currentUrl: '/test-param/user/456', + buildTimePagesData: [ + createMockRouteData({ + name: 'test-param-user-userId', + path: '/test-param/user/:userId()', + file: '/app/pages/test-param/user/[userId].vue', + }), + ], + expected: { parametrizedRoute: '/test-param/user/:userId()' }, + }, + ])('should match $description', ({ modules, currentUrl, buildTimePagesData, expected }) => { + const result = extractParametrizedRouteFromContext(modules, currentUrl, buildTimePagesData); + expect(result).toEqual(expected); + }); + }); + + describe('different folder structures', () => { + it.each([ + { + description: 'views folder instead of pages', + folderName: 'views', + modules: new Set(['app.vue', 'views/dashboard.vue']), + routeFile: '/app/views/dashboard.vue', + routePath: '/dashboard', + }, + { + description: 'routes folder', + folderName: 'routes', + modules: new Set(['app.vue', 'routes/api/users.vue']), + routeFile: '/app/routes/api/users.vue', + routePath: '/api/users', + }, + { + description: 'src/pages folder structure', + folderName: 'src/pages', + modules: new Set(['app.vue', 'src/pages/contact.vue']), + routeFile: '/app/src/pages/contact.vue', + routePath: '/contact', + }, + ])('should work with $description', ({ modules, routeFile, routePath }) => { + const buildTimePagesData = [ + createMockRouteData({ + name: 'test-route', + path: routePath, + file: routeFile, + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, routePath, buildTimePagesData); + expect(result).toEqual({ parametrizedRoute: routePath }); + }); + }); + + describe('multiple routes matching', () => { + it('should find the correct route when multiple routes exist', () => { + const modules = new Set(['app.vue', 'pages/test-param/[param].vue', 'components/ErrorButton.vue']); + + const buildTimePagesData = [ + createMockRouteData({ + name: 'client-error', + path: '/client-error', + file: '/app/pages/client-error.vue', + }), + createMockRouteData({ + name: 'fetch-server-error', + path: '/fetch-server-error', + file: '/app/pages/fetch-server-error.vue', + }), + createMockRouteData({ + name: 'test-param-param', + path: '/test-param/:param()', + file: '/app/pages/test-param/[param].vue', + }), + createMockRouteData({ + name: 'test-param-user-userId', + path: '/test-param/user/:userId()', + file: '/app/pages/test-param/user/[userId].vue', + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/test-param/123', buildTimePagesData); + expect(result).toEqual({ parametrizedRoute: '/test-param/:param()' }); + }); + + it('should return null for non-route files', () => { + const modules = new Set(['app.vue', 'components/Header.vue', 'components/Footer.vue', 'layouts/default.vue']); + + const buildTimePagesData = [ + createMockRouteData({ + name: 'home', + path: '/home', + file: '/app/pages/home.vue', + }), + ]; + + // /test is not in the module Set + const result = extractParametrizedRouteFromContext(modules, '/test', buildTimePagesData); + expect(result).toEqual(null); + }); + }); + + describe('complex path scenarios', () => { + it.each([ + { + description: 'absolute path with multiple directories', + file: 'folders/XYZ/some-folder/app/pages/client-error.vue', + module: 'pages/client-error.vue', + path: '/client-error', + }, + { + description: 'absolute path with dynamic route', + file: '/private/var/folders/XYZ/some-folder/app/pages/test-param/user/[userId].vue', + module: 'pages/test-param/user/[userId].vue', + path: '/test-param/user/:userId()', + }, + { + description: 'Windows-style path separators', + file: 'C:\\app\\pages\\dashboard\\index.vue', + module: 'pages/dashboard/index.vue', + path: '/dashboard', + }, + ])('should handle $description', ({ file, module, path }) => { + const modules = new Set([module, 'app.vue']); + const buildTimePagesData = [ + createMockRouteData({ + name: 'test-route', + path, + file, + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/test-url', buildTimePagesData); + expect(result).toEqual({ parametrizedRoute: path }); + }); + }); + + describe('no matches', () => { + it('should return null when no route data matches any module', () => { + const modules = new Set(['pages/non-existent.vue']); + const buildTimePagesData = [ + createMockRouteData({ + name: 'home', + path: '/home', + file: '/app/pages/home.vue', + }), + createMockRouteData({ + name: 'about', + path: '/about', + file: '/app/pages/about.vue', + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/non-existent', buildTimePagesData); + expect(result).toEqual(null); + }); + + it('should exclude root-level modules correctly', () => { + const modules = new Set(['app.vue', 'error.vue', 'middleware.js']); + const buildTimePagesData = [ + createMockRouteData({ + name: 'app', + path: '/', + file: '/app/app.vue', + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/', buildTimePagesData); + expect(result).toEqual(null); + }); + }); + + describe('malformed data handling', () => { + it('should handle modules with empty strings', () => { + const modules = new Set(['', 'pages/test.vue', ' ']); + const buildTimePagesData = [ + createMockRouteData({ + name: 'test', + path: '/test', + file: '/app/pages/test.vue', + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/test', buildTimePagesData); + expect(result).toEqual({ parametrizedRoute: '/test' }); + }); + }); + + describe('edge case file patterns', () => { + it('should handle file paths that do not follow standard patterns', () => { + const modules = new Set(['custom/special-route.vue']); + const buildTimePagesData = [ + createMockRouteData({ + name: 'special', + path: '/special', + file: '/unusual/path/structure/custom/special-route.vue', + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/special', buildTimePagesData); + expect(result).toEqual({ parametrizedRoute: '/special' }); + }); + + it('should not match when file patterns are completely different', () => { + const modules = new Set(['pages/user.vue']); + const buildTimePagesData = [ + createMockRouteData({ + name: 'admin', + path: '/admin', + file: '/app/admin/dashboard.vue', // Different structure + }), + ]; + + const result = extractParametrizedRouteFromContext(modules, '/user', buildTimePagesData); + expect(result).toEqual(null); + }); + }); +});