diff --git a/.size-limit.js b/.size-limit.js index 59ad29c3ccf8..08da6f5ce85b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -103,7 +103,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '34 KB', + limit: '35 KB', }, // React SDK (ESM) { @@ -215,7 +215,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '41 KB', + limit: '42 KB', }, // Node-Core SDK (ESM) { diff --git a/packages/core/src/utils/debug-ids.ts b/packages/core/src/utils/debug-ids.ts index f60e74c7cd26..97f30bbe816a 100644 --- a/packages/core/src/utils/debug-ids.ts +++ b/packages/core/src/utils/debug-ids.ts @@ -6,56 +6,82 @@ type StackString = string; type CachedResult = [string, string]; let parsedStackResults: Record | undefined; -let lastKeysCount: number | undefined; +let lastSentryKeysCount: number | undefined; +let lastNativeKeysCount: number | undefined; let cachedFilenameDebugIds: Record | undefined; /** * Returns a map of filenames to debug identifiers. + * Supports both proprietary _sentryDebugIds and native _debugIds (e.g., from Vercel) formats. */ export function getFilenameToDebugIdMap(stackParser: StackParser): Record { - const debugIdMap = GLOBAL_OBJ._sentryDebugIds; - if (!debugIdMap) { + const sentryDebugIdMap = GLOBAL_OBJ._sentryDebugIds; + const nativeDebugIdMap = GLOBAL_OBJ._debugIds; + + if (!sentryDebugIdMap && !nativeDebugIdMap) { return {}; } - const debugIdKeys = Object.keys(debugIdMap); + const sentryDebugIdKeys = sentryDebugIdMap ? Object.keys(sentryDebugIdMap) : []; + const nativeDebugIdKeys = nativeDebugIdMap ? Object.keys(nativeDebugIdMap) : []; // If the count of registered globals hasn't changed since the last call, we // can just return the cached result. - if (cachedFilenameDebugIds && debugIdKeys.length === lastKeysCount) { + if ( + cachedFilenameDebugIds && + sentryDebugIdKeys.length === lastSentryKeysCount && + nativeDebugIdKeys.length === lastNativeKeysCount + ) { return cachedFilenameDebugIds; } - lastKeysCount = debugIdKeys.length; - - // Build a map of filename -> debug_id. - cachedFilenameDebugIds = debugIdKeys.reduce>((acc, stackKey) => { - if (!parsedStackResults) { - parsedStackResults = {}; - } + lastSentryKeysCount = sentryDebugIdKeys.length; + lastNativeKeysCount = nativeDebugIdKeys.length; - const result = parsedStackResults[stackKey]; + // Build a map of filename -> debug_id from both sources + cachedFilenameDebugIds = {}; - if (result) { - acc[result[0]] = result[1]; - } else { - const parsedStack = stackParser(stackKey); - - for (let i = parsedStack.length - 1; i >= 0; i--) { - const stackFrame = parsedStack[i]; - const filename = stackFrame?.filename; - const debugId = debugIdMap[stackKey]; + if (!parsedStackResults) { + parsedStackResults = {}; + } - if (filename && debugId) { - acc[filename] = debugId; - parsedStackResults[stackKey] = [filename, debugId]; - break; + const processDebugIds = (debugIdKeys: string[], debugIdMap: Record): void => { + for (const key of debugIdKeys) { + const debugId = debugIdMap[key]; + const result = parsedStackResults?.[key]; + + if (result && cachedFilenameDebugIds && debugId) { + // Use cached filename but update with current debug ID + cachedFilenameDebugIds[result[0]] = debugId; + // Update cached result with new debug ID + if (parsedStackResults) { + parsedStackResults[key] = [result[0], debugId]; + } + } else if (debugId) { + const parsedStack = stackParser(key); + + for (let i = parsedStack.length - 1; i >= 0; i--) { + const stackFrame = parsedStack[i]; + const filename = stackFrame?.filename; + + if (filename && cachedFilenameDebugIds && parsedStackResults) { + cachedFilenameDebugIds[filename] = debugId; + parsedStackResults[key] = [filename, debugId]; + break; + } } } } + }; + + if (sentryDebugIdMap) { + processDebugIds(sentryDebugIdKeys, sentryDebugIdMap); + } - return acc; - }, {}); + // Native _debugIds will override _sentryDebugIds if same file + if (nativeDebugIdMap) { + processDebugIds(nativeDebugIdKeys, nativeDebugIdMap); + } return cachedFilenameDebugIds; } diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 2eb7f39f3a24..2ea6b391c613 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -41,6 +41,12 @@ export type InternalGlobal = { * file. */ _sentryDebugIds?: Record; + /** + * Native debug IDs implementation (e.g., from Vercel). + * This uses the same format as _sentryDebugIds but with a different global name. + * Keys are `error.stack` strings, values are debug IDs. + */ + _debugIds?: Record; /** * Raw module metadata that is injected by bundler plugins. * diff --git a/packages/core/test/lib/prepareEvent.test.ts b/packages/core/test/lib/prepareEvent.test.ts index d0fd86ae63f8..6472d3680fb0 100644 --- a/packages/core/test/lib/prepareEvent.test.ts +++ b/packages/core/test/lib/prepareEvent.test.ts @@ -19,6 +19,7 @@ import { clearGlobalScope } from '../testutils'; describe('applyDebugIds', () => { afterEach(() => { GLOBAL_OBJ._sentryDebugIds = undefined; + GLOBAL_OBJ._debugIds = undefined; }); it("should put debug IDs into an event's stack frames", () => { @@ -114,6 +115,139 @@ describe('applyDebugIds', () => { debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', }); }); + + it('should support native _debugIds format', () => { + GLOBAL_OBJ._debugIds = { + 'filename1.js\nfilename1.js': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + 'filename2.js\nfilename2.js': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + 'filename4.js\nfilename4.js': 'cccccccc-cccc-4ccc-cccc-cccccccccc', + }; + + const stackParser = createStackParser([0, line => ({ filename: line })]); + + const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { filename: 'filename1.js' }, + { filename: 'filename2.js' }, + { filename: 'filename1.js' }, + { filename: 'filename3.js' }, + ], + }, + }, + ], + }, + }; + + applyDebugIds(event, stackParser); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename1.js', + debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename2.js', + debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }); + + // expect not to contain an image for the stack frame that doesn't have a corresponding debug id + expect(event.exception?.values?.[0]?.stacktrace?.frames).not.toContainEqual( + expect.objectContaining({ + filename3: 'filename3.js', + debug_id: expect.any(String), + }), + ); + }); + + it('should merge both _sentryDebugIds and _debugIds when both exist', () => { + GLOBAL_OBJ._sentryDebugIds = { + 'filename1.js\nfilename1.js': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + 'filename2.js\nfilename2.js': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }; + + GLOBAL_OBJ._debugIds = { + 'filename3.js\nfilename3.js': 'cccccccc-cccc-4ccc-cccc-cccccccccc', + 'filename4.js\nfilename4.js': 'dddddddd-dddd-4ddd-dddd-dddddddddd', + }; + + const stackParser = createStackParser([0, line => ({ filename: line })]); + + const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { filename: 'filename1.js' }, + { filename: 'filename2.js' }, + { filename: 'filename3.js' }, + { filename: 'filename4.js' }, + ], + }, + }, + ], + }, + }; + + applyDebugIds(event, stackParser); + + // Should have debug IDs from both sources + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename1.js', + debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename2.js', + debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename3.js', + debug_id: 'cccccccc-cccc-4ccc-cccc-cccccccccc', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename4.js', + debug_id: 'dddddddd-dddd-4ddd-dddd-dddddddddd', + }); + }); + + it('should prioritize _debugIds over _sentryDebugIds for the same file', () => { + GLOBAL_OBJ._sentryDebugIds = { + 'filename1.js\nfilename1.js': 'old-debug-id-aaaa-aaaa-aaaa-aaaaaaaaaa', + }; + + GLOBAL_OBJ._debugIds = { + 'filename1.js\nfilename1.js': 'new-debug-id-bbbb-bbbb-bbbb-bbbbbbbbbb', + }; + + const stackParser = createStackParser([0, line => ({ filename: line })]); + + const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'filename1.js' }], + }, + }, + ], + }, + }; + + applyDebugIds(event, stackParser); + + // Should use the newer native _debugIds format + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename1.js', + debug_id: 'new-debug-id-bbbb-bbbb-bbbb-bbbbbbbbbb', + }); + }); }); describe('applyDebugMeta', () => { diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index c8dc35918198..d5c90962e581 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -8,7 +8,12 @@ import type { SentryBuildOptions } from './types'; * It is used to upload sourcemaps to Sentry. */ export async function handleRunAfterProductionCompile( - { releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' }, + { + releaseName, + distDir, + buildTool, + usesNativeDebugIds, + }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack'; usesNativeDebugIds?: boolean }, sentryBuildOptions: SentryBuildOptions, ): Promise { if (sentryBuildOptions.debug) { @@ -44,7 +49,11 @@ export async function handleRunAfterProductionCompile( await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); await sentryBuildPluginManager.createRelease(); - await sentryBuildPluginManager.injectDebugIds([distDir]); + + if (!usesNativeDebugIds) { + await sentryBuildPluginManager.injectDebugIds([distDir]); + } + await sentryBuildPluginManager.uploadSourcemaps([distDir], { // We don't want to prepare the artifacts because we injected debug ids manually before prepareArtifacts: false, diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 5c6372d6dec1..e46d3f6bb5c7 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,26 +1,37 @@ import { debug } from '@sentry/core'; import type { RouteManifest } from '../manifest/types'; -import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import { supportsNativeDebugIds } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. * * @param userNextConfig - The Next.js config object. - * @param turbopackOptions - The Turbopack options object. + * @param userSentryOptions - The Sentry build options object. + * @param routeManifest - The route manifest object. + * @param nextJsVersion - The Next.js version. * @returns The Turbopack config object. */ export function constructTurbopackConfig({ userNextConfig, + userSentryOptions, routeManifest, nextJsVersion, }: { userNextConfig: NextConfigObject; + userSentryOptions: SentryBuildOptions; routeManifest?: RouteManifest; nextJsVersion?: string; }): TurbopackOptions { + // If sourcemaps are disabled, we don't need to enable native debug ids as this will add build time. + const shouldEnableNativeDebugIds = + (supportsNativeDebugIds(nextJsVersion ?? '') && userNextConfig?.turbopack?.debugIds) ?? + userSentryOptions.sourcemaps?.disable !== true; + const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, + ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; const valueInjectionRules = generateValueInjectionRules({ diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 1fa245412f2c..28e038b6d0f2 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -673,4 +673,5 @@ export interface TurbopackOptions { conditions?: Record; moduleIds?: 'named' | 'deterministic'; root?: string; + debugIds?: boolean; } diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 40eb65e4e1e9..8d2d7781230b 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -66,6 +66,48 @@ export function supportsProductionCompileHook(version: string): boolean { return false; } +/** + * Checks if the current Next.js version supports native debug ids for turbopack. + * This feature was first introduced in Next.js v15.6.0-canary.36 and marked stable in Next.js v16 + * + * @param version - version string to check. + * @returns true if Next.js version supports native debug ids for turbopack builds + */ +export function supportsNativeDebugIds(version: string): boolean { + if (!version) { + return false; + } + + const { major, minor, prerelease } = parseSemver(version); + + if (major === undefined || minor === undefined) { + return false; + } + + // Next.js 16+ supports native debug ids + if (major >= 16) { + return true; + } + + // For Next.js 15, check if it's 15.6.0-canary.36+ + if (major === 15 && prerelease?.startsWith('canary.')) { + // Any canary version 15.7+ supports native debug ids + if (minor > 6) { + return true; + } + + // For 15.6 canary versions, check if it's canary.36 or higher + if (minor === 6) { + const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); + if (canaryNumber >= 36) { + return true; + } + } + } + + return false; +} + /** * Checks if the current Next.js version uses Turbopack as the default bundler. * Starting from Next.js 15.6.0-canary.38, turbopack became the default for `next build`. diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index eaac5b084a9e..31ea63f17a9c 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -14,6 +14,7 @@ import type { NextConfigFunction, NextConfigObject, SentryBuildOptions, + TurbopackOptions, } from './types'; import { detectActiveBundler, getNextjsVersion, supportsProductionCompileHook } from './util'; import { constructWebpackConfigFunction } from './webpack'; @@ -285,6 +286,17 @@ function getFinalConfigObject( ); } + let turboPackConfig: TurbopackOptions | undefined; + + if (isTurbopack) { + turboPackConfig = constructTurbopackConfig({ + userNextConfig: incomingUserNextConfigObject, + userSentryOptions, + routeManifest, + nextJsVersion, + }); + } + // If not explicitly set, turbopack uses the runAfterProductionCompile hook (as there are no alternatives), webpack does not. const shouldUseRunAfterProductionCompileHook = userSentryOptions?.useRunAfterProductionCompileHook ?? (isTurbopack ? true : false); @@ -292,9 +304,15 @@ function getFinalConfigObject( if (shouldUseRunAfterProductionCompileHook && supportsProductionCompileHook(nextJsVersion ?? '')) { if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { incomingUserNextConfigObject.compiler ??= {}; + incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { await handleRunAfterProductionCompile( - { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + { + releaseName, + distDir, + buildTool: isTurbopack ? 'turbopack' : 'webpack', + usesNativeDebugIds: isTurbopack ? turboPackConfig?.debugIds : undefined, + }, userSentryOptions, ); }; @@ -306,7 +324,12 @@ function getFinalConfigObject( const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' }; await target.apply(thisArg, argArray); await handleRunAfterProductionCompile( - { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + { + releaseName, + distDir, + buildTool: isTurbopack ? 'turbopack' : 'webpack', + usesNativeDebugIds: isTurbopack ? turboPackConfig?.debugIds : undefined, + }, userSentryOptions, ); }, @@ -379,11 +402,7 @@ function getFinalConfigObject( : {}), ...(isTurbopackSupported && isTurbopack ? { - turbopack: constructTurbopackConfig({ - userNextConfig: incomingUserNextConfigObject, - routeManifest, - nextJsVersion, - }), + turbopack: turboPackConfig, } : {}), }; diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 9750e4245894..ef37711eac48 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -25,12 +25,15 @@ describe('constructTurbopackConfig', () => { ], }; + const mockSentryOptions = {}; + describe('without existing turbopack config', () => { it('should create a basic turbopack config when no manifest is provided', () => { const userNextConfig: NextConfigObject = {}; const result = constructTurbopackConfig({ userNextConfig, + userSentryOptions: mockSentryOptions, }); expect(result).toEqual({}); @@ -600,6 +603,7 @@ describe('constructTurbopackConfig', () => { testVersions.forEach(version => { const result = constructTurbopackConfig({ userNextConfig, + userSentryOptions: mockSentryOptions, nextJsVersion: version, }); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 2dcff9889364..55fd13cf5dc4 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -97,6 +97,122 @@ describe('util', () => { }); }); + describe('supportsNativeDebugIds', () => { + describe('supported versions', () => { + it.each([ + // Next.js 16+ stable versions + ['16.0.0', 'Next.js 16.0.0 stable'], + ['16.0.1', 'Next.js 16.0.1 stable'], + ['16.1.0', 'Next.js 16.1.0 stable'], + ['16.2.5', 'Next.js 16.2.5 stable'], + + // Next.js 16+ pre-release versions + ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], + ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], + ['16.1.0-beta.2', 'Next.js 16.1.0-beta.2'], + + // Next.js 17+ + ['17.0.0', 'Next.js 17.0.0'], + ['18.0.0', 'Next.js 18.0.0'], + ['20.0.0', 'Next.js 20.0.0'], + + // Next.js 15.6.0-canary.36+ (boundary case) + ['15.6.0-canary.36', 'Next.js 15.6.0-canary.36 (exact threshold)'], + ['15.6.0-canary.37', 'Next.js 15.6.0-canary.37'], + ['15.6.0-canary.38', 'Next.js 15.6.0-canary.38'], + ['15.6.0-canary.40', 'Next.js 15.6.0-canary.40'], + ['15.6.0-canary.100', 'Next.js 15.6.0-canary.100'], + + // Next.js 15.7+ canary versions + ['15.7.0-canary.1', 'Next.js 15.7.0-canary.1'], + ['15.7.0-canary.50', 'Next.js 15.7.0-canary.50'], + ['15.8.0-canary.1', 'Next.js 15.8.0-canary.1'], + ['15.10.0-canary.1', 'Next.js 15.10.0-canary.1'], + ])('returns true for %s (%s)', version => { + expect(util.supportsNativeDebugIds(version)).toBe(true); + }); + }); + + describe('unsupported versions', () => { + it.each([ + // Next.js 15.6.0-canary.35 and below + ['15.6.0-canary.35', 'Next.js 15.6.0-canary.35 (just below threshold)'], + ['15.6.0-canary.34', 'Next.js 15.6.0-canary.34'], + ['15.6.0-canary.0', 'Next.js 15.6.0-canary.0'], + ['15.6.0-canary.1', 'Next.js 15.6.0-canary.1'], + + // Next.js 15.6.x stable releases (NOT canary) + ['15.6.0', 'Next.js 15.6.0 stable'], + ['15.6.1', 'Next.js 15.6.1 stable'], + ['15.6.2', 'Next.js 15.6.2 stable'], + ['15.6.10', 'Next.js 15.6.10 stable'], + + // Next.js 15.6.x rc releases (NOT canary) + ['15.6.0-rc.1', 'Next.js 15.6.0-rc.1'], + ['15.6.0-rc.2', 'Next.js 15.6.0-rc.2'], + + // Next.js 15.7+ stable releases (NOT canary) + ['15.7.0', 'Next.js 15.7.0 stable'], + ['15.8.0', 'Next.js 15.8.0 stable'], + ['15.10.0', 'Next.js 15.10.0 stable'], + + // Next.js 15.7+ rc/beta releases (NOT canary) + ['15.7.0-rc.1', 'Next.js 15.7.0-rc.1'], + ['15.7.0-beta.1', 'Next.js 15.7.0-beta.1'], + + // Next.js 15.5 and below (all versions) + ['15.5.0', 'Next.js 15.5.0'], + ['15.5.0-canary.100', 'Next.js 15.5.0-canary.100'], + ['15.4.1', 'Next.js 15.4.1'], + ['15.0.0', 'Next.js 15.0.0'], + ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], + + // Next.js 14.x and below + ['14.2.0', 'Next.js 14.2.0'], + ['14.0.0', 'Next.js 14.0.0'], + ['14.0.0-canary.50', 'Next.js 14.0.0-canary.50'], + ['13.5.0', 'Next.js 13.5.0'], + ['13.0.0', 'Next.js 13.0.0'], + ['12.0.0', 'Next.js 12.0.0'], + ])('returns false for %s (%s)', version => { + expect(util.supportsNativeDebugIds(version)).toBe(false); + }); + }); + + describe('edge cases', () => { + it.each([ + ['', 'empty string'], + ['invalid', 'invalid version string'], + ['15', 'missing minor and patch'], + ['15.6', 'missing patch'], + ['not.a.version', 'completely invalid'], + ['15.6.0-alpha.1', 'alpha prerelease (not canary)'], + ['15.6.0-beta.1', 'beta prerelease (not canary)'], + ])('returns false for %s (%s)', version => { + expect(util.supportsNativeDebugIds(version)).toBe(false); + }); + }); + + describe('canary number parsing edge cases', () => { + it.each([ + ['15.6.0-canary.', 'canary with no number'], + ['15.6.0-canary.abc', 'canary with non-numeric value'], + ['15.6.0-canary.35.extra', 'canary with extra segments'], + ])('handles malformed canary versions: %s (%s)', version => { + // Should not throw, just return appropriate boolean + expect(() => util.supportsNativeDebugIds(version)).not.toThrow(); + }); + + it('handles canary.36 exactly (boundary)', () => { + expect(util.supportsNativeDebugIds('15.6.0-canary.36')).toBe(true); + }); + + it('handles canary.35 exactly (boundary)', () => { + expect(util.supportsNativeDebugIds('15.6.0-canary.35')).toBe(false); + }); + }); + }); + describe('isTurbopackDefaultForVersion', () => { describe('returns true for versions where turbopack is default', () => { it.each([