diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index da762d5bb5ae..3404c2b2d3ec 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -79,13 +79,14 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@rollup/plugin-commonjs": "28.0.1", + "@sentry/bundler-plugin-core": "^4.3.0", + "@sentry/webpack-plugin": "^4.3.0", "@sentry-internal/browser-utils": "10.9.0", "@sentry/core": "10.9.0", "@sentry/node": "10.9.0", "@sentry/opentelemetry": "10.9.0", "@sentry/react": "10.9.0", "@sentry/vercel-edge": "10.9.0", - "@sentry/webpack-plugin": "^4.1.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts new file mode 100644 index 000000000000..3dfef3bbad08 --- /dev/null +++ b/packages/nextjs/src/config/getBuildPluginOptions.ts @@ -0,0 +1,97 @@ +import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugin-core'; +import * as path from 'path'; +import type { SentryBuildOptions } from './types'; + +/** + * Get Sentry Build Plugin options for the runAfterProductionCompile hook. + */ +export function getBuildPluginOptions({ + sentryBuildOptions, + releaseName, + distDirAbsPath, +}: { + sentryBuildOptions: SentryBuildOptions; + releaseName: string | undefined; + distDirAbsPath: string; +}): SentryBuildPluginOptions { + const sourcemapUploadAssets: string[] = []; + const sourcemapUploadIgnore: string[] = []; + + const filesToDeleteAfterUpload: string[] = []; + + // We need to convert paths to posix because Glob patterns use `\` to escape + // glob characters. This clashes with Windows path separators. + // See: https://www.npmjs.com/package/glob + const normalizedDistDirAbsPath = distDirAbsPath.replace(/\\/g, '/'); + + sourcemapUploadAssets.push( + path.posix.join(normalizedDistDirAbsPath, '**'), // Next.js build output + ); + if (sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload) { + filesToDeleteAfterUpload.push( + path.posix.join(normalizedDistDirAbsPath, '**', '*.js.map'), + path.posix.join(normalizedDistDirAbsPath, '**', '*.mjs.map'), + path.posix.join(normalizedDistDirAbsPath, '**', '*.cjs.map'), + ); + } + + return { + authToken: sentryBuildOptions.authToken, + headers: sentryBuildOptions.headers, + org: sentryBuildOptions.org, + project: sentryBuildOptions.project, + telemetry: sentryBuildOptions.telemetry, + debug: sentryBuildOptions.debug, + errorHandler: sentryBuildOptions.errorHandler, + reactComponentAnnotation: { + ...sentryBuildOptions.reactComponentAnnotation, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, + }, + silent: sentryBuildOptions.silent, + url: sentryBuildOptions.sentryUrl, + sourcemaps: { + disable: sentryBuildOptions.sourcemaps?.disable, + rewriteSources(source) { + if (source.startsWith('webpack://_N_E/')) { + return source.replace('webpack://_N_E/', ''); + } else if (source.startsWith('webpack://')) { + return source.replace('webpack://', ''); + } else { + return source; + } + }, + assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, + ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore, + filesToDeleteAfterUpload, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, + }, + release: + releaseName !== undefined + ? { + inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. + name: releaseName, + create: sentryBuildOptions.release?.create, + finalize: sentryBuildOptions.release?.finalize, + dist: sentryBuildOptions.release?.dist, + vcsRemote: sentryBuildOptions.release?.vcsRemote, + setCommits: sentryBuildOptions.release?.setCommits, + deploy: sentryBuildOptions.release?.deploy, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, + } + : { + inject: false, + create: false, + finalize: false, + }, + bundleSizeOptimizations: { + ...sentryBuildOptions.bundleSizeOptimizations, + }, + _metaOptions: { + loggerPrefixOverride: '[@sentry/nextjs]', + telemetry: { + metaFramework: 'nextjs', + }, + }, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions, + }; +} diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts new file mode 100644 index 000000000000..01979b497c72 --- /dev/null +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -0,0 +1,59 @@ +import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core'; +import { loadModule } from '@sentry/core'; +import { getBuildPluginOptions } from './getBuildPluginOptions'; +import type { SentryBuildOptions } from './types'; + +/** + * This function is called by Next.js after the production build is complete. + * It is used to upload sourcemaps to Sentry. + */ +export async function handleRunAfterProductionCompile( + { releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' }, + sentryBuildOptions: SentryBuildOptions, +): Promise { + // We don't want to do anything for webpack at this point because the plugin already handles this + // TODO: Actually implement this for webpack as well + if (buildTool === 'webpack') { + return; + } + + if (sentryBuildOptions.debug) { + // eslint-disable-next-line no-console + console.debug('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + } + + const { createSentryBuildPluginManager } = + loadModule<{ createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType }>( + '@sentry/bundler-plugin-core', + module, + ) ?? {}; + + if (!createSentryBuildPluginManager) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.', + ); + return; + } + + const sentryBuildPluginManager = createSentryBuildPluginManager( + getBuildPluginOptions({ + sentryBuildOptions, + releaseName, + distDirAbsPath: distDir, + }), + { + buildTool, + loggerPrefix: '[@sentry/nextjs]', + }, + ); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + 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, + }); + await sentryBuildPluginManager.deleteArtifacts(); +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 18cdc2d38cfc..1ca5eaa6bab0 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -52,6 +52,9 @@ export type NextConfigObject = { env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 turbopack?: TurbopackOptions; + compiler?: { + runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise | void; + }; }; export type SentryBuildOptions = { @@ -504,6 +507,15 @@ export type SentryBuildOptions = { * Use with caution in production environments. */ _experimental?: Partial<{ + /** + * When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads + * into a single operation after turbopack builds complete, reducing build time. + * + * When false, use the traditional approach of uploading sourcemaps during each webpack build. + * + * @default false + */ + useRunAfterProductionCompileHook?: boolean; thirdPartyOriginStackFrames: boolean; }>; }; diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index a88e68a57135..a5def59a66fe 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -1,3 +1,4 @@ +import { parseSemver } from '@sentry/core'; import * as fs from 'fs'; import { sync as resolveSync } from 'resolve'; @@ -27,3 +28,39 @@ function resolveNextjsPackageJson(): string | undefined { return undefined; } } + +/** + * Checks if the current Next.js version supports the runAfterProductionCompile hook. + * This hook was introduced in Next.js 15.4.1. (https://github.com/vercel/next.js/pull/77345) + * + * @returns true if Next.js version is 15.4.1 or higher + */ +export function supportsProductionCompileHook(): boolean { + const version = getNextjsVersion(); + if (!version) { + return false; + } + + const { major, minor, patch } = parseSemver(version); + + if (major === undefined || minor === undefined || patch === undefined) { + return false; + } + + if (major > 15) { + return true; + } + + // For major version 15, check if it's 15.4.1 or higher + if (major === 15) { + if (minor > 4) { + return true; + } + if (minor === 4 && patch >= 1) { + return true; + } + return false; + } + + return false; +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index d1117855f06b..4558e5349c5a 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -5,6 +5,7 @@ import { getSentryRelease } from '@sentry/node'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { handleRunAfterProductionCompile } from './handleRunAfterProductionCompile'; import { createRouteManifest } from './manifest/createRouteManifest'; import type { RouteManifest } from './manifest/types'; import { constructTurbopackConfig } from './turbopack'; @@ -14,7 +15,7 @@ import type { NextConfigObject, SentryBuildOptions, } from './types'; -import { getNextjsVersion } from './util'; +import { getNextjsVersion, supportsProductionCompileHook } from './util'; import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; @@ -293,6 +294,59 @@ function getFinalConfigObject( } } + if (userSentryOptions?._experimental?.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) { + if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { + incomingUserNextConfigObject.compiler ??= {}; + incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { + await handleRunAfterProductionCompile( + { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + userSentryOptions, + ); + }; + } else if (typeof incomingUserNextConfigObject.compiler.runAfterProductionCompile === 'function') { + incomingUserNextConfigObject.compiler.runAfterProductionCompile = new Proxy( + incomingUserNextConfigObject.compiler.runAfterProductionCompile, + { + async apply(target, thisArg, argArray) { + const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' }; + await target.apply(thisArg, argArray); + await handleRunAfterProductionCompile( + { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + userSentryOptions, + ); + }, + }, + ); + } else { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.', + ); + } + } + + // Enable source maps for turbopack builds + if (isTurbopackSupported && isTurbopack && !userSentryOptions.sourcemaps?.disable) { + // Only set if not already configured by user + if (incomingUserNextConfigObject.productionBrowserSourceMaps === undefined) { + // eslint-disable-next-line no-console + console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.'); + incomingUserNextConfigObject.productionBrowserSourceMaps = true; + + // Enable source map deletion if not explicitly disabled + if (userSentryOptions.sourcemaps?.deleteSourcemapsAfterUpload === undefined) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', + ); + userSentryOptions.sourcemaps = { + ...userSentryOptions.sourcemaps, + deleteSourcemapsAfterUpload: true, + }; + } + } + } + return { ...incomingUserNextConfigObject, ...(nextMajor && nextMajor >= 15 diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts new file mode 100644 index 000000000000..1120084ec76e --- /dev/null +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getBuildPluginOptions } from '../../src/config/getBuildPluginOptions'; +import type { SentryBuildOptions } from '../../src/config/types'; + +describe('getBuildPluginOptions', () => { + const mockReleaseName = 'test-release-1.0.0'; + const mockDistDirAbsPath = '/path/to/.next'; + + describe('basic functionality', () => { + it('returns correct build plugin options with minimal configuration', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result).toMatchObject({ + authToken: 'test-token', + org: 'test-org', + project: 'test-project', + sourcemaps: { + assets: ['/path/to/.next/**'], + ignore: [], + filesToDeleteAfterUpload: [], + rewriteSources: expect.any(Function), + }, + release: { + inject: false, + name: mockReleaseName, + create: undefined, + finalize: undefined, + }, + _metaOptions: { + loggerPrefixOverride: '[@sentry/nextjs]', + telemetry: { + metaFramework: 'nextjs', + }, + }, + bundleSizeOptimizations: {}, + }); + }); + + it('normalizes Windows paths to posix for glob patterns', () => { + const windowsPath = 'C:\\Users\\test\\.next'; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: windowsPath, + }); + + expect(result.sourcemaps?.assets).toEqual(['C:/Users/test/.next/**']); + }); + }); + + describe('sourcemap configuration', () => { + it('configures file deletion when deleteSourcemapsAfterUpload is enabled', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ + '/path/to/.next/**/*.js.map', + '/path/to/.next/**/*.mjs.map', + '/path/to/.next/**/*.cjs.map', + ]); + }); + + it('does not configure file deletion when deleteSourcemapsAfterUpload is disabled', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: false, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([]); + }); + + it('uses custom sourcemap assets when provided', () => { + const customAssets = ['custom/path/**', 'another/path/**']; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + assets: customAssets, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.assets).toEqual(customAssets); + }); + + it('uses custom sourcemap ignore patterns when provided', () => { + const customIgnore = ['**/vendor/**', '**/node_modules/**']; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + ignore: customIgnore, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.ignore).toEqual(customIgnore); + }); + + it('disables sourcemaps when disable flag is set', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + disable: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.disable).toBe(true); + }); + }); + + describe('source rewriting functionality', () => { + it('rewrites webpack sources correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + const rewriteSources = result.sourcemaps?.rewriteSources; + expect(rewriteSources).toBeDefined(); + + if (rewriteSources) { + // Test webpack://_N_E/ prefix removal + expect(rewriteSources('webpack://_N_E/src/pages/index.js', {})).toBe('src/pages/index.js'); + + // Test general webpack:// prefix removal + expect(rewriteSources('webpack://project/src/components/Button.js', {})).toBe( + 'project/src/components/Button.js', + ); + + // Test no rewriting for normal paths + expect(rewriteSources('src/utils/helpers.js', {})).toBe('src/utils/helpers.js'); + expect(rewriteSources('./components/Layout.tsx', {})).toBe('./components/Layout.tsx'); + } + }); + }); + + describe('release configuration', () => { + it('configures release with injection disabled when release name is provided', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + release: { + create: true, + finalize: true, + dist: 'production', + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.release).toMatchObject({ + inject: false, + name: mockReleaseName, + create: true, + finalize: true, + dist: 'production', + }); + }); + + it('configures release as disabled when no release name is provided', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: undefined, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.release).toMatchObject({ + inject: false, + create: false, + finalize: false, + }); + }); + + it('merges webpack plugin release options correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + release: { + create: true, + vcsRemote: 'origin', + }, + unstable_sentryWebpackPluginOptions: { + release: { + setCommits: { + auto: true, + }, + deploy: { + env: 'production', + }, + }, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + // The unstable_sentryWebpackPluginOptions.release is spread at the end and may override base properties + expect(result.release).toHaveProperty('setCommits.auto', true); + expect(result.release).toHaveProperty('deploy.env', 'production'); + }); + }); + + describe('react component annotation', () => { + it('merges react component annotation options correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + reactComponentAnnotation: { + enabled: true, + }, + unstable_sentryWebpackPluginOptions: { + reactComponentAnnotation: { + enabled: false, // This will override the base setting + }, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + // The unstable options override the base options - in this case enabled should be false + expect(result.reactComponentAnnotation).toHaveProperty('enabled', false); + }); + }); + + describe('other configuration options', () => { + it('passes through all standard configuration options', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + headers: { 'Custom-Header': 'value' }, + telemetry: false, + debug: true, + errorHandler: vi.fn(), + silent: true, + sentryUrl: 'https://custom.sentry.io', + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result).toMatchObject({ + authToken: 'test-token', + headers: { 'Custom-Header': 'value' }, + org: 'test-org', + project: 'test-project', + telemetry: false, + debug: true, + errorHandler: sentryBuildOptions.errorHandler, + silent: true, + url: 'https://custom.sentry.io', + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + }); + }); + + it('merges unstable webpack plugin options correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + unstable_sentryWebpackPluginOptions: { + applicationKey: 'test-app-key', + sourcemaps: { + disable: false, + }, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result).toMatchObject({ + applicationKey: 'test-app-key', + sourcemaps: expect.objectContaining({ + disable: false, + }), + }); + }); + }); + + describe('edge cases', () => { + it('handles undefined release name gracefully', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: undefined, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.release).toMatchObject({ + inject: false, + create: false, + finalize: false, + }); + }); + + it('handles empty sourcemaps configuration', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: {}, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps).toMatchObject({ + disable: undefined, + assets: ['/path/to/.next/**'], + ignore: [], + filesToDeleteAfterUpload: [], + rewriteSources: expect.any(Function), + }); + }); + + it('handles complex nested path structures', () => { + const complexPath = '/very/deep/nested/path/with/multiple/segments/.next'; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: complexPath, + }); + + expect(result.sourcemaps?.assets).toEqual([`${complexPath}/**`]); + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ + `${complexPath}/**/*.js.map`, + `${complexPath}/**/*.mjs.map`, + `${complexPath}/**/*.cjs.map`, + ]); + }); + }); +}); diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts new file mode 100644 index 000000000000..22973cb6f15b --- /dev/null +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -0,0 +1,284 @@ +import { loadModule } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleRunAfterProductionCompile } from '../../src/config/handleRunAfterProductionCompile'; +import type { SentryBuildOptions } from '../../src/config/types'; + +vi.mock('@sentry/core', () => ({ + loadModule: vi.fn(), +})); + +vi.mock('../../src/config/getBuildPluginOptions', () => ({ + getBuildPluginOptions: vi.fn(() => ({ + org: 'test-org', + project: 'test-project', + sourcemaps: {}, + })), +})); + +describe('handleRunAfterProductionCompile', () => { + const mockCreateSentryBuildPluginManager = vi.fn(); + const mockSentryBuildPluginManager = { + telemetry: { + emitBundlerPluginExecutionSignal: vi.fn().mockResolvedValue(undefined), + }, + createRelease: vi.fn().mockResolvedValue(undefined), + injectDebugIds: vi.fn().mockResolvedValue(undefined), + uploadSourcemaps: vi.fn().mockResolvedValue(undefined), + deleteArtifacts: vi.fn().mockResolvedValue(undefined), + }; + + const mockSentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSentryBuildPluginManager.mockReturnValue(mockSentryBuildPluginManager); + (loadModule as any).mockReturnValue({ + createSentryBuildPluginManager: mockCreateSentryBuildPluginManager, + }); + }); + + describe('turbopack builds', () => { + it('executes all build steps for turbopack builds', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockSentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal).toHaveBeenCalledTimes(1); + expect(mockSentryBuildPluginManager.createRelease).toHaveBeenCalledTimes(1); + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith(['/path/to/.next']); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith(['/path/to/.next'], { + prepareArtifacts: false, + }); + expect(mockSentryBuildPluginManager.deleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('calls createSentryBuildPluginManager with correct options', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'test-org', + project: 'test-project', + sourcemaps: expect.any(Object), + }), + { + buildTool: 'turbopack', + loggerPrefix: '[@sentry/nextjs]', + }, + ); + }); + + it('handles debug mode correctly', async () => { + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const debugOptions = { + ...mockSentryBuildOptions, + debug: true, + }; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + debugOptions, + ); + + expect(consoleSpy).toHaveBeenCalledWith('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + + consoleSpy.mockRestore(); + }); + }); + + describe('webpack builds', () => { + it('skips execution for webpack builds', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'webpack', + }, + mockSentryBuildOptions, + ); + + expect(loadModule).not.toHaveBeenCalled(); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + }); + + it('does not log debug message for webpack builds when debug is enabled', async () => { + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const debugOptions = { + ...mockSentryBuildOptions, + debug: true, + }; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'webpack', + }, + debugOptions, + ); + + expect(consoleSpy).not.toHaveBeenCalledWith('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + + consoleSpy.mockRestore(); + }); + }); + + describe('error handling', () => { + it('handles missing bundler plugin core gracefully', async () => { + (loadModule as any).mockReturnValue(null); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.', + ); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('handles missing createSentryBuildPluginManager export gracefully', async () => { + (loadModule as any).mockReturnValue({}); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.', + ); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('propagates errors from build plugin manager operations', async () => { + const mockError = new Error('Test error'); + mockSentryBuildPluginManager.createRelease.mockRejectedValue(mockError); + + await expect( + handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ), + ).rejects.toThrow('Test error'); + }); + }); + + describe('step execution order', () => { + it('executes build steps in correct order', async () => { + const executionOrder: string[] = []; + + mockSentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal.mockImplementation(async () => { + executionOrder.push('telemetry'); + }); + mockSentryBuildPluginManager.createRelease.mockImplementation(async () => { + executionOrder.push('createRelease'); + }); + mockSentryBuildPluginManager.injectDebugIds.mockImplementation(async () => { + executionOrder.push('injectDebugIds'); + }); + mockSentryBuildPluginManager.uploadSourcemaps.mockImplementation(async () => { + executionOrder.push('uploadSourcemaps'); + }); + mockSentryBuildPluginManager.deleteArtifacts.mockImplementation(async () => { + executionOrder.push('deleteArtifacts'); + }); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(executionOrder).toEqual([ + 'telemetry', + 'createRelease', + 'injectDebugIds', + 'uploadSourcemaps', + 'deleteArtifacts', + ]); + }); + }); + + describe('path handling', () => { + it('correctly passes distDir to debug ID injection', async () => { + const customDistDir = '/custom/dist/path'; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: customDistDir, + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith([customDistDir]); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith([customDistDir], { + prepareArtifacts: false, + }); + }); + + it('works with relative paths', async () => { + const relativeDistDir = '.next'; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: relativeDistDir, + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith([relativeDistDir]); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith([relativeDistDir], { + prepareArtifacts: false, + }); + }); + }); +}); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts new file mode 100644 index 000000000000..01b94480ea5f --- /dev/null +++ b/packages/nextjs/test/config/util.test.ts @@ -0,0 +1,170 @@ +import * as fs from 'fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as util from '../../src/config/util'; + +// Mock fs to control what getNextjsVersion reads +vi.mock('fs'); + +describe('util', () => { + describe('supportsProductionCompileHook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('supported versions', () => { + it('returns true for Next.js 15.4.1', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1' })); + + const result = util.supportsProductionCompileHook(); + expect(result).toBe(true); + }); + + it('returns true for Next.js 15.4.2', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.2' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for Next.js 15.5.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.5.0' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for Next.js 16.0.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '16.0.0' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for Next.js 17.0.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '17.0.0' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for supported canary versions', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-canary.42' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for supported rc versions', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-rc.1' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + }); + + describe('unsupported versions', () => { + it('returns false for Next.js 15.4.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for Next.js 15.3.9', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.3.9' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for Next.js 15.0.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.0.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for Next.js 14.2.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '14.2.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for unsupported canary versions', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0-canary.42' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for invalid version strings', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: 'invalid.version' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('handles versions with build metadata', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1+build.123' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('handles versions with pre-release identifiers', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-alpha.1' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns false for versions missing patch number', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for versions missing minor number', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + }); + + describe('version boundary tests', () => { + it('returns false for 15.4.0 (just below threshold)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns true for 15.4.1 (exact threshold)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for 15.4.2 (just above threshold)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.2' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns false for 15.3.999 (high patch but wrong minor)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.3.999' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + }); + }); +}); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 3b872d810c49..9303223c97bc 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -269,6 +269,429 @@ describe('withSentryConfig', () => { }); }); + describe('turbopack sourcemap configuration', () => { + const originalTurbopack = process.env.TURBOPACK; + + afterEach(() => { + vi.restoreAllMocks(); + process.env.TURBOPACK = originalTurbopack; + }); + + it('enables productionBrowserSourceMaps for supported turbopack builds when sourcemaps are not disabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable productionBrowserSourceMaps when sourcemaps are disabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: { + disable: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('does not enable productionBrowserSourceMaps when turbopack is not enabled', () => { + delete process.env.TURBOPACK; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('does not enable productionBrowserSourceMaps when turbopack version is not supported', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.2.0'); // unsupported version + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('preserves user-configured productionBrowserSourceMaps setting', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMaps = { + ...exportedNextConfig, + productionBrowserSourceMaps: false, // user explicitly disabled + }; + + const finalConfig = materializeFinalNextConfig(configWithSourceMaps); + + expect(finalConfig.productionBrowserSourceMaps).toBe(false); + }); + + it('preserves user-configured productionBrowserSourceMaps: true setting', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMaps = { + ...exportedNextConfig, + productionBrowserSourceMaps: true, // user explicitly enabled + }; + + const sentryOptions = { + sourcemaps: { + disable: true, // Sentry disabled, but user wants Next.js sourcemaps + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithSourceMaps, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('automatically enables deleteSourcemapsAfterUpload for turbopack builds when not explicitly set', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Use a clean config without productionBrowserSourceMaps to ensure it gets auto-enabled + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: {}, // no deleteSourcemapsAfterUpload setting + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + // Both productionBrowserSourceMaps and deleteSourcemapsAfterUpload should be enabled + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + }); + + it('preserves explicitly configured deleteSourcemapsAfterUpload setting', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = { + sourcemaps: { + deleteSourcemapsAfterUpload: false, // user wants to keep sourcemaps + }, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(sentryOptions.sourcemaps.deleteSourcemapsAfterUpload).toBe(false); + }); + + it('does not modify deleteSourcemapsAfterUpload when sourcemaps are disabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = { + sourcemaps: { + disable: true, + }, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(sentryOptions.sourcemaps).not.toHaveProperty('deleteSourcemapsAfterUpload'); + }); + + it('does not enable deleteSourcemapsAfterUpload when user pre-configured productionBrowserSourceMaps: true', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMapsPreEnabled = { + ...exportedNextConfig, + productionBrowserSourceMaps: true, // User already enabled + }; + + const sentryOptions = { + sourcemaps: {}, // no explicit deleteSourcemapsAfterUpload setting + }; + + materializeFinalNextConfig(configWithSourceMapsPreEnabled, undefined, sentryOptions); + + // Should NOT automatically enable deletion because productionBrowserSourceMaps was already set by user + expect(sentryOptions.sourcemaps).not.toHaveProperty('deleteSourcemapsAfterUpload'); + }); + + it('does not enable sourcemaps or deletion when user explicitly sets productionBrowserSourceMaps: false', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMapsDisabled = { + ...exportedNextConfig, + productionBrowserSourceMaps: false, // User explicitly disabled + }; + + const sentryOptions = { + sourcemaps: {}, // no explicit deleteSourcemapsAfterUpload setting + }; + + const finalConfig = materializeFinalNextConfig(configWithSourceMapsDisabled, undefined, sentryOptions); + + // Should NOT modify productionBrowserSourceMaps or enable deletion when user explicitly set to false + expect(finalConfig.productionBrowserSourceMaps).toBe(false); + expect(sentryOptions.sourcemaps).not.toHaveProperty('deleteSourcemapsAfterUpload'); + }); + + it('logs correct message when enabling sourcemaps for turbopack', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + materializeFinalNextConfig(cleanConfig); + + expect(consoleSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.', + ); + + consoleSpy.mockRestore(); + }); + + it('warns about automatic sourcemap deletion for turbopack builds', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: {}, // triggers automatic deletion + }; + + materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', + ); + + consoleWarnSpy.mockRestore(); + }); + + describe('version compatibility', () => { + it('enables sourcemaps for Next.js 15.3.0', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('enables sourcemaps for Next.js 15.4.0', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('enables sourcemaps for Next.js 16.0.0', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable sourcemaps for Next.js 15.2.9', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.2.9'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('enables sourcemaps for supported canary versions', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0-canary.28'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable sourcemaps for unsupported canary versions', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0-canary.27'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles undefined sourcemaps option', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = {}; // no sourcemaps property + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('handles empty sourcemaps object', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: {}, // empty object + }; + + materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + }); + + it('works when TURBOPACK env var is truthy string', () => { + process.env.TURBOPACK = 'true'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable sourcemaps when TURBOPACK env var is falsy', () => { + process.env.TURBOPACK = ''; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('works correctly with tunnel route configuration', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + tunnelRoute: '/custom-tunnel', + sourcemaps: {}, + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + expect(finalConfig.rewrites).toBeInstanceOf(Function); + }); + + it('works correctly with custom release configuration', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Clear environment variable to test custom release name + const originalSentryRelease = process.env.SENTRY_RELEASE; + delete process.env.SENTRY_RELEASE; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.env; + delete cleanConfig.productionBrowserSourceMaps; // Ensure it gets auto-enabled + + const sentryOptions = { + release: { + name: 'custom-release-1.0.0', + }, + sourcemaps: {}, + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + expect(finalConfig.env).toHaveProperty('_sentryRelease', 'custom-release-1.0.0'); + + // Restore original env var + if (originalSentryRelease) { + process.env.SENTRY_RELEASE = originalSentryRelease; + } + }); + + it('does not interfere with other Next.js configuration options', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithOtherOptions = { + ...exportedNextConfig, + assetPrefix: 'https://cdn.example.com', + basePath: '/app', + distDir: 'custom-dist', + }; + + const finalConfig = materializeFinalNextConfig(configWithOtherOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(finalConfig.assetPrefix).toBe('https://cdn.example.com'); + expect(finalConfig.basePath).toBe('/app'); + expect(finalConfig.distDir).toBe('custom-dist'); + }); + + it('works correctly when turbopack config already exists', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithTurbopack = { + ...exportedNextConfig, + turbopack: { + resolveAlias: { + '@': './src', + }, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithTurbopack); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.turbopack?.resolveAlias).toEqual({ '@': './src' }); + }); + }); + }); + describe('release injection behavior', () => { afterEach(() => { vi.restoreAllMocks(); @@ -335,4 +758,253 @@ describe('withSentryConfig', () => { expect(finalConfig.env).toHaveProperty('_sentryRelease', 'env-release-1.5.0'); }); }); + + describe('runAfterProductionCompile hook integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets up runAfterProductionCompile hook when experimental flag is enabled and version is supported', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + it('does not set up hook when experimental flag is disabled', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: false, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('does not set up hook when Next.js version is not supported', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('preserves existing runAfterProductionCompile hook using proxy', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const originalHook = vi.fn().mockResolvedValue(undefined); + const configWithExistingHook = { + ...exportedNextConfig, + compiler: { + runAfterProductionCompile: originalHook, + }, + }; + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithExistingHook, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + expect(finalConfig.compiler?.runAfterProductionCompile).not.toBe(originalHook); + }); + + it('warns when existing runAfterProductionCompile is not a function', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const configWithInvalidHook = { + ...exportedNextConfig, + compiler: { + runAfterProductionCompile: 'invalid-hook' as any, + }, + }; + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + materializeFinalNextConfig(configWithInvalidHook, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('creates compiler object when it does not exist', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const configWithoutCompiler = { ...exportedNextConfig }; + delete configWithoutCompiler.compiler; + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithoutCompiler, undefined, sentryOptions); + + expect(finalConfig.compiler).toBeDefined(); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + it('works with turbopack builds when TURBOPACK env is set', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + + delete process.env.TURBOPACK; + }); + + it('works with webpack builds when TURBOPACK env is not set', () => { + delete process.env.TURBOPACK; + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + }); + + describe('experimental flag handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('respects useRunAfterProductionCompileHook: true', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + it('respects useRunAfterProductionCompileHook: false', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: false, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('does not set up hook when experimental flag is undefined', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + // useRunAfterProductionCompileHook not specified + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('does not set up hook when _experimental is undefined', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + // no _experimental property + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('combines experimental flag with other configurations correctly', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + sourcemaps: {}, + tunnelRoute: '/tunnel', + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + // Should have both turbopack sourcemap config AND runAfterProductionCompile hook + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + expect(finalConfig.rewrites).toBeInstanceOf(Function); + + delete process.env.TURBOPACK; + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 225ff96259e7..c3a81b3cc097 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4718,6 +4718,18 @@ resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -6946,6 +6958,16 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.1.tgz#371415afc602f6b2ba0987b51123bd34d1603193" integrity sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA== +"@sentry/babel-plugin-component-annotate@4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz#6c616e6d645f49f15f83b891ef42a795ba4dbb3f" + integrity sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ== + +"@sentry/babel-plugin-component-annotate@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" + integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== + "@sentry/bundler-plugin-core@4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.0.tgz#c1b2f7a890a44e5ac5decc984a133aacf6147dd4" @@ -6974,6 +6996,34 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.2.0.tgz#b607937f7cd0a769aa26974c4af3fca94abad63f" + integrity sha512-EDG6ELSEN/Dzm4KUQOynoI2suEAdPdgwaBXVN4Ww705zdrYT79OGh51rkz74KGhovt7GukaPf0Z9LJwORXUbhg== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.2.0" + "@sentry/cli" "^2.51.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^9.3.2" + magic-string "0.30.8" + unplugin "1.0.1" + +"@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" + integrity sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.3.0" + "@sentry/cli" "^2.51.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^9.3.2" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.52.0": version "2.52.0" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.52.0.tgz#05178cd819c2a33eb22a6e90bf7bb8f853f1b476" @@ -7050,12 +7100,12 @@ "@sentry/bundler-plugin-core" "4.1.0" unplugin "1.0.1" -"@sentry/webpack-plugin@^4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.1.1.tgz#638c6b65cbc19b5027ffbb6bcd68094e0b0f82c6" - integrity sha512-2gFWcQMW1HdJDo/7rADeFs9crkH02l+mW4O1ORbxSjuegauyp1W8SBe7EfPoXbUmLdA3zwnpIxEXjjQpP5Etzg== +"@sentry/webpack-plugin@^4.1.1", "@sentry/webpack-plugin@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.3.0.tgz#a96db7d8ada8646ec3ffdec2a7db6143c8061e85" + integrity sha512-K4nU1SheK/tvyakBws2zfd+MN6hzmpW+wPTbSbDWn1+WL9+g9hsPh8hjFFiVe47AhhUoUZ3YgiH2HyeHXjHflA== dependencies: - "@sentry/bundler-plugin-core" "4.1.1" + "@sentry/bundler-plugin-core" "4.3.0" unplugin "1.0.1" uuid "^9.0.0" @@ -9410,7 +9460,7 @@ postcss "^8.4.47" source-map-js "^1.2.0" -"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4": +"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4": version "3.5.17" resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz#c518871276e26593612bdab36f3f5bcd053b13bf" integrity sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww== @@ -13457,10 +13507,10 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -14205,9 +14255,6 @@ detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^7.0.1" detective-stylus@^4.0.0: version "4.0.0" @@ -14242,14 +14289,6 @@ detective-vue2@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a" integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA== - dependencies: - "@dependents/detective-less" "^5.0.1" - "@vue/compiler-sfc" "^3.5.13" - detective-es6 "^5.0.1" - detective-sass "^6.0.1" - detective-scss "^5.0.1" - detective-stylus "^5.0.1" - detective-typescript "^14.0.0" deterministic-object-hash@^1.3.1: version "1.3.1" @@ -16781,9 +16820,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== - dependencies: - node-domexception "^1.0.0" - web-streams-polyfill "^3.0.3" fflate@0.8.2, fflate@^0.8.2: version "0.8.2" @@ -17143,11 +17179,11 @@ foreach@^2.0.5: integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= foreground-child@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" - integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: - cross-spawn "^7.0.0" + cross-spawn "^7.0.6" signal-exit "^4.0.1" form-data@^4.0.0: @@ -19855,9 +19891,9 @@ jackspeak@^3.1.2: "@pkgjs/parseargs" "^0.11.0" jackspeak@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.0.tgz#c489c079f2b636dc4cbe9b0312a13ff1282e561b" - integrity sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw== + version "4.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== dependencies: "@isaacs/cliui" "^8.0.2" @@ -22128,11 +22164,11 @@ minimatch@5.1.0, minimatch@^5.0.1, minimatch@^5.1.0: brace-expansion "^2.0.1" minimatch@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" - integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== + version "10.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== dependencies: - brace-expansion "^2.0.1" + "@isaacs/brace-expansion" "^5.0.0" minimatch@^7.4.1: version "7.4.6" @@ -22978,11 +23014,6 @@ node-cron@^3.0.3: dependencies: uuid "8.3.2" -node-domexception@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37" @@ -31088,7 +31119,7 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== -web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1: +web-streams-polyfill@^3.1.1: version "3.3.3" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==