diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index ea9f0aeb63ea..9ae0a5ee0bb2 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -12,9 +12,7 @@ type OriginalStackFrameResponse = { const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string; - next?: { - version?: string; - }; + _sentryNextJsVersion: string | undefined; }; /** @@ -39,9 +37,15 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev try { if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) { const frames = stackTraceParser.parse(hint.originalException.stack); + const nextJsVersion = globalWithInjectedValues._sentryNextJsVersion; + + // If we for whatever reason don't have a Next.js version, + // we don't want to symbolicate as this previously lead to infinite loops + if (!nextJsVersion) { + return event; + } - const nextjsVersion = globalWithInjectedValues.next?.version || '0.0.0'; - const parsedNextjsVersion = nextjsVersion ? parseSemver(nextjsVersion) : {}; + const parsedNextjsVersion = parseSemver(nextJsVersion); let resolvedFrames: ({ originalCodeFrame: string | null; @@ -83,7 +87,9 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev context_line: contextLine, post_context: postContextLines, function: resolvedFrame.originalStackFrame.methodName, - filename: resolvedFrame.originalStackFrame.file || undefined, + filename: resolvedFrame.originalStackFrame.file + ? stripWebpackInternalPrefix(resolvedFrame.originalStackFrame.file) + : undefined, lineno: resolvedFrame.originalStackFrame.lineNumber || resolvedFrame.originalStackFrame.line1 || undefined, colno: resolvedFrame.originalStackFrame.column || resolvedFrame.originalStackFrame.column1 || undefined, @@ -281,3 +287,21 @@ function parseOriginalCodeFrame(codeFrame: string): { postContextLines, }; } + +/** + * Strips webpack-internal prefixes from filenames to clean up stack traces. + * + * Examples: + * - "webpack-internal:///./components/file.tsx" -> "./components/file.tsx" + * - "webpack-internal:///(app-pages-browser)/./components/file.tsx" -> "./components/file.tsx" + */ +function stripWebpackInternalPrefix(filename: string): string | undefined { + if (!filename) { + return filename; + } + + const webpackInternalRegex = /^webpack-internal:(?:\/+)?(?:\([^)]*\)\/)?(.+)$/; + const match = filename.match(webpackInternalRegex); + + return match ? match[1] : filename; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 50dd1a14588a..76d98fda25e8 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,8 +1,8 @@ import { debug } from '@sentry/core'; import * as chalk from 'chalk'; -import * as path from 'path'; import type { RouteManifest } from '../manifest/types'; -import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShortcut } from '../types'; +import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import { generateValueInjectionRules } from './generateValueInjectionRules'; /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. @@ -14,30 +14,23 @@ import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShort export function constructTurbopackConfig({ userNextConfig, routeManifest, + nextJsVersion, }: { userNextConfig: NextConfigObject; routeManifest?: RouteManifest; + nextJsVersion?: string; }): TurbopackOptions { const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, }; - if (routeManifest) { - newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { - matcher: '**/instrumentation-client.*', - rule: { - loaders: [ - { - loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), - options: { - values: { - _sentryRouteManifest: JSON.stringify(routeManifest), - }, - }, - }, - ], - }, - }); + const valueInjectionRules = generateValueInjectionRules({ + routeManifest, + nextJsVersion, + }); + + for (const { matcher, rule } of valueInjectionRules) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { matcher, rule }); } return newConfig; @@ -53,7 +46,7 @@ export function constructTurbopackConfig({ */ export function safelyAddTurbopackRule( existingRules: TurbopackOptions['rules'], - { matcher, rule }: { matcher: string; rule: TurbopackRuleConfigItemOrShortcut }, + { matcher, rule }: TurbopackMatcherWithRule, ): TurbopackOptions['rules'] { if (!existingRules) { return { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts new file mode 100644 index 000000000000..58cf7cdd0a15 --- /dev/null +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; +import type { RouteManifest } from '../manifest/types'; +import type { JSONValue, TurbopackMatcherWithRule } from '../types'; + +/** + * Generate the value injection rules for client and server in turbopack config. + */ +export function generateValueInjectionRules({ + routeManifest, + nextJsVersion, +}: { + routeManifest?: RouteManifest; + nextJsVersion?: string; +}): TurbopackMatcherWithRule[] { + const rules: TurbopackMatcherWithRule[] = []; + const isomorphicValues: Record = {}; + let clientValues: Record = {}; + let serverValues: Record = {}; + + if (nextJsVersion) { + // This is used to determine version-based dev-symbolication behavior + isomorphicValues._sentryNextJsVersion = nextJsVersion; + } + + if (routeManifest) { + clientValues._sentryRouteManifest = JSON.stringify(routeManifest); + } + + if (Object.keys(isomorphicValues).length > 0) { + clientValues = { ...clientValues, ...isomorphicValues }; + serverValues = { ...serverValues, ...isomorphicValues }; + } + + // Client value injection + if (Object.keys(clientValues).length > 0) { + rules.push({ + matcher: '**/instrumentation-client.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + options: { + values: clientValues, + }, + }, + ], + }, + }); + } + + // Server value injection + if (Object.keys(serverValues).length > 0) { + rules.push({ + matcher: '**/instrumentation.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + options: { + values: serverValues, + }, + }, + ], + }, + }); + } + + return rules; +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index b29fbb6881af..18cdc2d38cfc 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -621,7 +621,7 @@ export type EnhancedGlobal = typeof GLOBAL_OBJ & { SENTRY_RELEASES?: { [key: string]: { id: string } }; }; -type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; +export type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; type TurbopackLoaderItem = | string @@ -637,6 +637,11 @@ type TurbopackRuleCondition = { export type TurbopackRuleConfigItemOrShortcut = TurbopackLoaderItem[] | TurbopackRuleConfigItem; +export type TurbopackMatcherWithRule = { + matcher: string; + rule: TurbopackRuleConfigItemOrShortcut; +}; + type TurbopackRuleConfigItemOptions = { loaders: TurbopackLoaderItem[]; as?: string; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 9c9c479cd724..b336e1e2ee9b 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -45,6 +45,7 @@ export function constructWebpackConfigFunction( userSentryOptions: SentryBuildOptions = {}, releaseName: string | undefined, routeManifest: RouteManifest | undefined, + nextJsVersion: string | undefined, ): WebpackConfigFunction { // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that @@ -90,7 +91,15 @@ export function constructWebpackConfigFunction( const newConfig = setUpModuleRules(rawNewConfig); // Add a loader which will inject code that sets global values - addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName, routeManifest); + addValueInjectionLoader({ + newConfig, + userNextConfig, + userSentryOptions, + buildContext, + releaseName, + routeManifest, + nextJsVersion, + }); addOtelWarningIgnoreRule(newConfig); @@ -682,14 +691,23 @@ function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWi */ // TODO: Remove this loader and replace it with a nextConfig.env (https://web.archive.org/web/20240917153554/https://nextjs.org/docs/app/api-reference/next-config-js/env) or define based (https://github.com/vercel/next.js/discussions/71476) approach. // In order to remove this loader though we need to make sure the minimum supported Next.js version includes this PR (https://github.com/vercel/next.js/pull/61194), otherwise the nextConfig.env based approach will not work, as our SDK code is not processed by Next.js. -function addValueInjectionLoader( - newConfig: WebpackConfigObjectWithModuleRules, - userNextConfig: NextConfigObject, - userSentryOptions: SentryBuildOptions, - buildContext: BuildContext, - releaseName: string | undefined, - routeManifest: RouteManifest | undefined, -): void { +function addValueInjectionLoader({ + newConfig, + userNextConfig, + userSentryOptions, + buildContext, + releaseName, + routeManifest, + nextJsVersion, +}: { + newConfig: WebpackConfigObjectWithModuleRules; + userNextConfig: NextConfigObject; + userSentryOptions: SentryBuildOptions; + buildContext: BuildContext; + releaseName: string | undefined; + routeManifest: RouteManifest | undefined; + nextJsVersion: string | undefined; +}): void { const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; // Check if release creation is disabled to prevent injection that breaks build determinism @@ -710,6 +728,8 @@ function addValueInjectionLoader( // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + // This is used to determine version-based dev-symbolication behavior + _sentryNextJsVersion: nextJsVersion, }; const serverValues = { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 57fff867f64a..4404fded7e36 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -314,12 +314,19 @@ function getFinalConfigObject( webpack: isTurbopack || userSentryOptions.disableSentryWebpackConfig ? incomingUserNextConfigObject.webpack // just return the original webpack config - : constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest), + : constructWebpackConfigFunction( + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + nextJsVersion, + ), ...(isTurbopackSupported && isTurbopack ? { turbopack: constructTurbopackConfig({ userNextConfig: incomingUserNextConfigObject, routeManifest, + nextJsVersion, }), } : {}), diff --git a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts new file mode 100644 index 000000000000..4305aad537a8 --- /dev/null +++ b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts @@ -0,0 +1,261 @@ +import type { Event, EventHint, SpanJSON } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { devErrorSymbolicationEventProcessor } from '../../src/common/devErrorSymbolicationEventProcessor'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + debug: { + error: vi.fn(), + }, + suppressTracing: vi.fn(fn => fn()), + }; +}); + +vi.mock('stacktrace-parser', () => ({ + parse: vi.fn(), +})); + +global.fetch = vi.fn(); + +describe('devErrorSymbolicationEventProcessor', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (GLOBAL_OBJ as any)._sentryNextJsVersion; + delete (GLOBAL_OBJ as any)._sentryBasePath; + }); + + describe('Next.js version handling', () => { + it('should return event early when _sentryNextJsVersion is undefined', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: new Error('test error'), + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = undefined; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return event early when _sentryNextJsVersion is null', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: new Error('test error'), + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = null; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return event early when _sentryNextJsVersion is empty string', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: new Error('test error'), + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = ''; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should return original event when no originalException in hint', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = {}; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return original event when originalException is not an Error', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: 'string error', + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return original event when Error has no stack', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const errorWithoutStack = new Error('test error'); + delete errorWithoutStack.stack; + + const mockHint: EventHint = { + originalException: errorWithoutStack, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + describe('transaction span filtering', () => { + it('should filter out spans with __nextjs_original-stack-frame URLs', async () => { + const mockEvent: Event = { + type: 'transaction', + spans: [ + { + data: { + 'http.url': 'http://localhost:3000/__nextjs_original-stack-frame?file=test.js', + }, + }, + { + data: { + 'http.url': 'http://localhost:3000/__nextjs_original-stack-frames', + }, + }, + { + data: { + 'http.url': 'http://localhost:3000/api/users', + }, + }, + { + data: { + 'other.attribute': 'value', + }, + }, + ] as unknown as SpanJSON[], // :^) + }; + + const mockHint: EventHint = {}; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result?.spans).toHaveLength(2); + expect(result?.spans?.[0]?.data?.['http.url']).toBe('http://localhost:3000/api/users'); + expect(result?.spans?.[1]?.data?.['other.attribute']).toBe('value'); + }); + + it('should preserve spans without http.url attribute', async () => { + const mockEvent: Event = { + type: 'transaction', + spans: [ + { + data: { + 'other.attribute': 'value', + }, + }, + ] as unknown as SpanJSON[], + }; + + const mockHint: EventHint = {}; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result?.spans).toHaveLength(1); + expect(result?.spans?.[0]?.data?.['other.attribute']).toBe('value'); + }); + + it('should handle spans with non-string http.url attribute', async () => { + const mockEvent: Event = { + type: 'transaction', + spans: [ + { + data: { + 'http.url': 123, // non-string + }, + }, + ] as unknown as SpanJSON[], + }; + + const mockHint: EventHint = {}; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result?.spans).toHaveLength(1); + }); + }); +}); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 813d3c0f8894..9750e4245894 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -105,7 +105,6 @@ describe('constructTurbopackConfig', () => { expect(loader.loader).toBe(windowsLoaderPath); expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); - // Restore the original mock behavior pathResolveSpy.mockReturnValue('/mocked/path/to/valueInjectionLoader.js'); }); }); @@ -189,7 +188,7 @@ describe('constructTurbopackConfig', () => { const userNextConfig: NextConfigObject = { turbopack: { rules: { - '**/instrumentation-client.*': existingRule, + '**/instrumentation.*': existingRule, }, }, }; @@ -201,7 +200,19 @@ describe('constructTurbopackConfig', () => { expect(result).toEqual({ rules: { - '**/instrumentation-client.*': existingRule, + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': existingRule, }, }); }); @@ -268,6 +279,458 @@ describe('constructTurbopackConfig', () => { }); }); }); + + describe('additional edge cases', () => { + it('should handle undefined turbopack property', () => { + const userNextConfig: NextConfigObject = { + turbopack: undefined, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle null turbopack property', () => { + const userNextConfig: NextConfigObject = { + turbopack: null as any, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: '15.0.0', + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '15.0.0', + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '15.0.0', + }, + }, + }, + ], + }, + }, + }); + }); + + it('should preserve other turbopack properties when adding rules', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + '@components': './src/components', + }, + rules: { + '*.css': ['css-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion: '14.0.0', + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + '@components': './src/components', + }, + rules: { + '*.css': ['css-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle empty rules object in existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + rules: {}, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle multiple colliding instrumentation rules', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation.*': ['existing-loader'], + '**/instrumentation-client.*': { loaders: ['client-loader'] }, + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion: '14.0.0', + }); + + // Should preserve existing rules and not add new ones + expect(result).toEqual({ + rules: { + '**/instrumentation.*': ['existing-loader'], + '**/instrumentation-client.*': { loaders: ['client-loader'] }, + }, + }); + }); + }); + + describe('Next.js version injection', () => { + it('should create turbopack config with Next.js version rule when nextJsVersion is provided', () => { + const userNextConfig: NextConfigObject = {}; + const nextJsVersion = '15.1.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + + it('should create turbopack config with both manifest and Next.js version rules', () => { + const userNextConfig: NextConfigObject = {}; + const nextJsVersion = '14.2.5'; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + + it('should merge Next.js version rule with existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + const nextJsVersion = '15.0.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle different Next.js version formats', () => { + const userNextConfig: NextConfigObject = {}; + const testVersions = ['13.0.0', '14.1.2-canary.1', '15.0.0-rc.1', '16.0.0']; + + testVersions.forEach(version => { + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: version, + }); + + expect(result.rules).toBeDefined(); + expect(result.rules!['**/instrumentation.*']).toBeDefined(); + + const rule = result.rules!['**/instrumentation.*']; + const ruleWithLoaders = rule as { loaders: Array<{ loader: string; options: any }> }; + expect(ruleWithLoaders.loaders[0]!.options.values._sentryNextJsVersion).toBe(version); + }); + }); + + it('should not create Next.js version rule when nextJsVersion is undefined', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: undefined, + }); + + expect(result).toEqual({}); + }); + + it('should not create Next.js version rule when nextJsVersion is empty string', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: '', + }); + + expect(result).toEqual({}); + }); + + it('should not override existing instrumentation rule when nextJsVersion is provided', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { custom: 'value' }, + }, + ], + }; + + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation.*': existingRule, + }, + }, + }; + const nextJsVersion = '15.1.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + '**/instrumentation.*': existingRule, + }, + }); + }); + + it('should handle all parameters together with existing config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@components': './src/components', + }, + rules: { + '*.scss': ['sass-loader'], + }, + }, + }; + const nextJsVersion = '14.0.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion, + }); + + expect(result).toEqual({ + resolveAlias: { + '@components': './src/components', + }, + rules: { + '*.scss': ['sass-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + }); }); describe('safelyAddTurbopackRule', () => { @@ -440,4 +903,101 @@ describe('safelyAddTurbopackRule', () => { }); }); }); + + describe('additional edge cases for safelyAddTurbopackRule', () => { + it('should handle falsy values in rules', () => { + const existingRules = { + '*.css': ['css-loader'], + '*.disabled': false as any, + '*.null': null as any, + } as any; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.disabled': false, + '*.null': null, + '*.test.js': mockRule, + } as any); + }); + + it('should handle undefined rule value', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: undefined as any, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': undefined, + }); + }); + + it('should handle complex matchers with special characters', () => { + const existingRules = {}; + const complexMatcher = '**/node_modules/**/*.{js,ts}'; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: complexMatcher, + rule: mockRule, + }); + + expect(result).toEqual({ + [complexMatcher]: mockRule, + }); + }); + + it('should preserve nested rule objects', () => { + const complexRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { + nested: { + deep: 'value', + array: [1, 2, 3], + }, + }, + }, + ], + as: 'javascript/auto', + condition: 'test-condition', + }; + + const result = safelyAddTurbopackRule(undefined, { + matcher: '*.complex.js', + rule: complexRule, + }); + + expect(result).toEqual({ + '*.complex.js': complexRule, + }); + }); + + it('should handle matcher that matches an object property key pattern', () => { + const existingRules = { + '*.test': ['test-loader'], + 'test.*': ['pattern-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test', + rule: mockRule, + }); + + // Should not override the existing rule + expect(result).toEqual({ + '*.test': ['test-loader'], + 'test.*': ['pattern-loader'], + }); + }); + }); }); diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts new file mode 100644 index 000000000000..74e3d24b2cc4 --- /dev/null +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -0,0 +1,338 @@ +import * as path from 'path'; +import { describe, expect, it, vi } from 'vitest'; +import type { RouteManifest } from '../../../src/config/manifest/types'; +import { generateValueInjectionRules } from '../../../src/config/turbopack/generateValueInjectionRules'; + +// Mock path.resolve to return a predictable loader path +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + resolve: vi.fn().mockReturnValue('/mocked/path/to/valueInjectionLoader.js'), + }; +}); + +describe('generateValueInjectionRules', () => { + const mockRouteManifest: RouteManifest = { + dynamicRoutes: [{ path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }], + staticRoutes: [ + { path: '/users', regex: '/users' }, + { path: '/api/health', regex: '/api/health' }, + ], + }; + + describe('with no inputs', () => { + it('should return empty array when no inputs are provided', () => { + const result = generateValueInjectionRules({}); + + expect(result).toEqual([]); + }); + + it('should return empty array when inputs are undefined', () => { + const result = generateValueInjectionRules({ + routeManifest: undefined, + nextJsVersion: undefined, + }); + + expect(result).toEqual([]); + }); + }); + + describe('with nextJsVersion only', () => { + it('should generate client and server rules when nextJsVersion is provided', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(result).toHaveLength(2); + + // Client rule + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule).toBeDefined(); + expect(clientRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }); + + // Server rule + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + expect(serverRule).toBeDefined(); + expect(serverRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }); + }); + }); + + describe('with routeManifest only', () => { + it('should generate only client rule when routeManifest is provided', () => { + const result = generateValueInjectionRules({ + routeManifest: mockRouteManifest, + }); + + expect(result).toHaveLength(1); + + // Only client rule should exist + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule).toBeDefined(); + expect(clientRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }); + + // Server rule should not exist + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + expect(serverRule).toBeUndefined(); + }); + + it('should handle empty route manifest', () => { + const emptyManifest: RouteManifest = { + dynamicRoutes: [], + staticRoutes: [], + }; + + const result = generateValueInjectionRules({ + routeManifest: emptyManifest, + }); + + expect(result).toHaveLength(1); + + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule?.rule).toMatchObject({ + loaders: [ + { + options: { + values: { + _sentryRouteManifest: JSON.stringify(emptyManifest), + }, + }, + }, + ], + }); + }); + + it('should handle complex route manifest', () => { + const complexManifest: RouteManifest = { + dynamicRoutes: [ + { path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }, + { path: '/posts/[...slug]', regex: '/posts/(.*)', paramNames: ['slug'] }, + { path: '/category/[category]/[id]', regex: '/category/([^/]+)/([^/]+)', paramNames: ['category', 'id'] }, + ], + staticRoutes: [ + { path: '/', regex: '/' }, + { path: '/about', regex: '/about' }, + { path: '/api/health', regex: '/api/health' }, + { path: '/api/users', regex: '/api/users' }, + ], + }; + + const result = generateValueInjectionRules({ + routeManifest: complexManifest, + }); + + expect(result).toHaveLength(1); + + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule?.rule).toMatchObject({ + loaders: [ + { + options: { + values: { + _sentryRouteManifest: JSON.stringify(complexManifest), + }, + }, + }, + ], + }); + }); + }); + + describe('with both nextJsVersion and routeManifest', () => { + it('should generate both client and server rules with combined values', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + routeManifest: mockRouteManifest, + }); + + expect(result).toHaveLength(2); + + // Client rule should have both values + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule).toBeDefined(); + expect(clientRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }); + + // Server rule should have only nextJsVersion + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + expect(serverRule).toBeDefined(); + expect(serverRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }); + }); + + it('should handle all combinations of truthy and falsy values', () => { + const testCases = [ + { nextJsVersion: '14.0.0', routeManifest: mockRouteManifest, expectedRules: 2 }, + { nextJsVersion: '', routeManifest: mockRouteManifest, expectedRules: 1 }, + { nextJsVersion: '14.0.0', routeManifest: undefined, expectedRules: 2 }, + { nextJsVersion: '', routeManifest: undefined, expectedRules: 0 }, + ]; + + testCases.forEach(({ nextJsVersion, routeManifest, expectedRules }) => { + const result = generateValueInjectionRules({ + nextJsVersion: nextJsVersion || undefined, + routeManifest, + }); + + expect(result).toHaveLength(expectedRules); + }); + }); + }); + + describe('path resolution', () => { + it('should call path.resolve with correct arguments', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + + generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + }); + + it('should use the resolved path in loader configuration', () => { + const customLoaderPath = '/custom/path/to/loader.js'; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockReturnValue(customLoaderPath); + + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(result).toHaveLength(2); + + result.forEach(rule => { + const ruleWithLoaders = rule.rule as unknown as { loaders: Array<{ loader: string }> }; + expect(ruleWithLoaders.loaders[0]?.loader).toBe(customLoaderPath); + }); + }); + }); + + describe('rule structure validation', () => { + it('should generate rules with correct structure', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + routeManifest: mockRouteManifest, + }); + + result.forEach(rule => { + // Validate top-level structure + expect(rule).toHaveProperty('matcher'); + expect(rule).toHaveProperty('rule'); + expect(typeof rule.matcher).toBe('string'); + + // Validate rule structure + const ruleObj = rule.rule as unknown as { loaders: Array }; + expect(ruleObj).toHaveProperty('loaders'); + expect(Array.isArray(ruleObj.loaders)).toBe(true); + expect(ruleObj.loaders).toHaveLength(1); + + // Validate loader structure + const loader = ruleObj.loaders[0]; + expect(loader).toHaveProperty('loader'); + expect(loader).toHaveProperty('options'); + expect(typeof loader.loader).toBe('string'); + expect(loader.options).toHaveProperty('values'); + expect(typeof loader.options.values).toBe('object'); + }); + }); + + it('should generate different matchers for client and server rules', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + const matchers = result.map(rule => rule.matcher); + expect(matchers).toContain('**/instrumentation-client.*'); + expect(matchers).toContain('**/instrumentation.*'); + expect(matchers).toHaveLength(2); + }); + + it('should ensure client rules come before server rules', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(result).toHaveLength(2); + expect(result[0]?.matcher).toBe('**/instrumentation-client.*'); + expect(result[1]?.matcher).toBe('**/instrumentation.*'); + }); + }); + + describe('edge cases', () => { + it('should handle zero-length nextJsVersion', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '', + }); + + expect(result).toEqual([]); + }); + + it('should handle whitespace-only nextJsVersion', () => { + const result = generateValueInjectionRules({ + nextJsVersion: ' ', + }); + + expect(result).toHaveLength(2); + + result.forEach(rule => { + const ruleObj = rule.rule as unknown as { loaders: Array<{ options: { values: any } }> }; + expect(ruleObj.loaders[0]?.options.values._sentryNextJsVersion).toBe(' '); + }); + }); + }); +});