11import { createSentrySDK } from 'sentry' ;
2- import * as fs from 'fs ' ;
2+ import { rm } from 'node:fs/promises ' ;
33import { glob } from 'glob' ;
44import type { ResolvedConfig } from 'vite' ;
55import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
66import { sentryOnBuildEnd } from '../../../src/vite/buildEnd/handleOnBuildEnd' ;
77import type { SentryReactRouterBuildOptions } from '../../../src/vite/types' ;
88
99vi . mock ( 'sentry' ) ;
10- vi . mock ( 'fs' , ( ) => ( {
11- promises : {
12- rm : vi . fn ( ) . mockResolvedValue ( undefined ) ,
13- } ,
14- } ) ) ;
10+ vi . mock ( 'node:fs/promises' , ( ) => {
11+ const rm = vi . fn ( ) . mockResolvedValue ( undefined ) ;
12+ return { rm, default : { rm } } ;
13+ } ) ;
1514vi . mock ( 'glob' ) ;
1615
1716type TestConfig = ResolvedConfig & {
@@ -59,12 +58,16 @@ describe('sentryOnBuildEnd', () => {
5958
6059 beforeEach ( ( ) => {
6160 vi . clearAllMocks ( ) ;
62- vi . mocked ( createSentrySDK ) . mockReturnValue ( mockSentrySdkInstance as unknown as ReturnType < typeof createSentrySDK > ) ;
61+ mockSentrySdkInstance . sourcemap . upload . mockReset ( ) ;
62+ vi . mocked ( createSentrySDK )
63+ . mockReset ( )
64+ . mockReturnValue ( mockSentrySdkInstance as unknown as ReturnType < typeof createSentrySDK > ) ;
6365 vi . mocked ( glob ) . mockResolvedValue ( [ '/build/file1.map' , '/build/file2.map' ] ) ;
64- vi . mocked ( fs . promises . rm ) . mockResolvedValue ( undefined ) ;
66+ vi . mocked ( rm ) . mockResolvedValue ( undefined ) ;
6567 } ) ;
6668
6769 afterEach ( ( ) => {
70+ vi . restoreAllMocks ( ) ;
6871 vi . resetModules ( ) ;
6972 } ) ;
7073
@@ -140,75 +143,83 @@ describe('sentryOnBuildEnd', () => {
140143 } ) ;
141144 } ) ;
142145
143- it ( 'should not upload or inject source maps when disabled via top-level sourcemaps.disable' , async ( ) => {
146+ it ( 'creates a release for both projects and uploads to each with one debug ID injection' , async ( ) => {
147+ const upload = vi . fn ( ) ;
148+ vi . mocked ( createSentrySDK ) . mockImplementation ( options => {
149+ const clientUpload : ReturnType < typeof createSentrySDK > [ 'sourcemap' ] [ 'upload' ] = params =>
150+ upload ( options ?. project , params ) ;
151+ return {
152+ ...mockSentrySdkInstance ,
153+ sourcemap : {
154+ ...mockSentrySdkInstance . sourcemap ,
155+ upload : clientUpload ,
156+ } ,
157+ } as unknown as ReturnType < typeof createSentrySDK > ;
158+ } ) ;
159+ const sentryConfig : SentryReactRouterBuildOptions = {
160+ ...defaultConfig . viteConfig . sentryConfig ,
161+ project : [ 'frontend' , 'backend' ] ,
162+ release : { name : 'v1.0.0' } ,
163+ } ;
144164 const config = {
145165 ...defaultConfig ,
146- viteConfig : {
147- ...defaultConfig . viteConfig ,
148- sentryConfig : {
149- ...defaultConfig . viteConfig . sentryConfig ,
150- sourcemaps : { disable : true } ,
151- } ,
152- } as unknown as TestConfig ,
166+ viteConfig : { ...defaultConfig . viteConfig , sentryConfig } ,
153167 } ;
154168
155169 // @ts -expect-error - mocking the React config
156170 await sentryOnBuildEnd ( config ) ;
157171
158- expect ( mockSentrySdkInstance . sourcemap . inject ) . not . toHaveBeenCalled ( ) ;
159- expect ( mockSentrySdkInstance . sourcemap . upload ) . not . toHaveBeenCalled ( ) ;
172+ expect ( mockSentrySdkInstance . release . create ) . toHaveBeenCalledExactlyOnceWith ( {
173+ orgVersion : 'v1.0.0' ,
174+ project : 'frontend,backend' ,
175+ } ) ;
176+ expect ( mockSentrySdkInstance . sourcemap . inject ) . toHaveBeenCalledExactlyOnceWith ( { directory : '/build' } ) ;
177+ expect ( upload ) . toHaveBeenCalledTimes ( 2 ) ;
178+ expect ( upload ) . toHaveBeenCalledWith ( 'frontend' , { directory : '/build' , release : 'v1.0.0' } ) ;
179+ expect ( upload ) . toHaveBeenCalledWith ( 'backend' , { directory : '/build' , release : 'v1.0.0' } ) ;
160180 } ) ;
161181
162- // `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so
163- // injection must still run and the maps must survive.
164- it ( 'should inject debug IDs but skip upload and deletion when disable is "disable-upload"' , async ( ) => {
182+ it ( 'should not upload or inject source maps when disabled via top-level sourcemaps.disable' , async ( ) => {
165183 const config = {
166184 ...defaultConfig ,
167185 viteConfig : {
168186 ...defaultConfig . viteConfig ,
169187 sentryConfig : {
170188 ...defaultConfig . viteConfig . sentryConfig ,
171- sourcemaps : { disable : 'disable-upload' } ,
189+ sourcemaps : { disable : true } ,
172190 } ,
173191 } as unknown as TestConfig ,
174192 } ;
175193
176194 // @ts -expect-error - mocking the React config
177195 await sentryOnBuildEnd ( config ) ;
178196
179- expect ( mockSentrySdkInstance . sourcemap . inject ) . toHaveBeenCalledWith ( { directory : '/build' } ) ;
197+ expect ( mockSentrySdkInstance . sourcemap . inject ) . not . toHaveBeenCalled ( ) ;
180198 expect ( mockSentrySdkInstance . sourcemap . upload ) . not . toHaveBeenCalled ( ) ;
181199 expect ( glob ) . not . toHaveBeenCalled ( ) ;
200+ expect ( rm ) . not . toHaveBeenCalled ( ) ;
182201 } ) ;
183202
184- // Deleting maps that were never uploaded would leave the user with neither.
185- it ( 'should not delete source maps when upload is disabled' , async ( ) => {
203+ // `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so
204+ // injection must still run and the maps must survive.
205+ it ( 'should inject debug IDs but skip upload and deletion when disable is "disable-upload"' , async ( ) => {
186206 const config = {
187207 ...defaultConfig ,
188208 viteConfig : {
189209 ...defaultConfig . viteConfig ,
190210 sentryConfig : {
191211 ...defaultConfig . viteConfig . sentryConfig ,
192- sourcemaps : { disable : true } ,
212+ sourcemaps : { disable : 'disable-upload' } ,
193213 } ,
194214 } as unknown as TestConfig ,
195215 } ;
196216
197217 // @ts -expect-error - mocking the React config
198218 await sentryOnBuildEnd ( config ) ;
199219
220+ expect ( mockSentrySdkInstance . sourcemap . inject ) . toHaveBeenCalledWith ( { directory : '/build' } ) ;
221+ expect ( mockSentrySdkInstance . sourcemap . upload ) . not . toHaveBeenCalled ( ) ;
200222 expect ( glob ) . not . toHaveBeenCalled ( ) ;
201- expect ( fs . promises . rm ) . not . toHaveBeenCalled ( ) ;
202- } ) ;
203-
204- it ( 'should delete source maps after upload with default pattern' , async ( ) => {
205- // @ts -expect-error - mocking the React config
206- await sentryOnBuildEnd ( defaultConfig ) ;
207-
208- expect ( glob ) . toHaveBeenCalledWith ( [ '/build/**/*.map' ] , {
209- absolute : true ,
210- nodir : true ,
211- } ) ;
212223 } ) ;
213224
214225 it ( 'should delete custom files after upload when specified' , async ( ) => {
@@ -258,13 +269,6 @@ describe('sentryOnBuildEnd', () => {
258269 consoleSpy . mockRestore ( ) ;
259270 } ) ;
260271
261- it ( 'should inject debug IDs before uploading source maps' , async ( ) => {
262- // @ts -expect-error - mocking the React config
263- await sentryOnBuildEnd ( defaultConfig ) ;
264-
265- expect ( mockSentrySdkInstance . sourcemap . inject ) . toHaveBeenCalledWith ( { directory : '/build' } ) ;
266- } ) ;
267-
268272 it ( 'should handle errors during debug ID injection gracefully' , async ( ) => {
269273 const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
270274 mockSentrySdkInstance . sourcemap . inject . mockRejectedValueOnce ( new Error ( 'Injection failed' ) ) ;
@@ -278,15 +282,25 @@ describe('sentryOnBuildEnd', () => {
278282 consoleSpy . mockRestore ( ) ;
279283 } ) ;
280284
281- it ( 'should handle errors during source map upload gracefully' , async ( ) => {
282- const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
283- mockSentrySdkInstance . sourcemap . upload . mockRejectedValueOnce ( new Error ( 'Upload failed' ) ) ;
285+ it ( 'logs a failed project upload and still runs cleanup' , async ( ) => {
286+ const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => undefined ) ;
287+ const uploadError = new Error ( 'Backend upload failed' ) ;
288+ mockSentrySdkInstance . sourcemap . upload . mockResolvedValueOnce ( undefined ) . mockRejectedValueOnce ( uploadError ) ;
289+ const sentryConfig : SentryReactRouterBuildOptions = {
290+ ...defaultConfig . viteConfig . sentryConfig ,
291+ project : [ 'frontend' , 'backend' ] ,
292+ } ;
293+ const config = {
294+ ...defaultConfig ,
295+ viteConfig : { ...defaultConfig . viteConfig , sentryConfig } ,
296+ } ;
284297
285298 // @ts -expect-error - mocking the React config
286- await sentryOnBuildEnd ( defaultConfig ) ;
299+ await sentryOnBuildEnd ( config ) ;
287300
288- expect ( consoleSpy ) . toHaveBeenCalledWith ( '[Sentry] Could not upload sourcemaps' , expect . any ( Error ) ) ;
289- consoleSpy . mockRestore ( ) ;
301+ expect ( mockSentrySdkInstance . sourcemap . upload ) . toHaveBeenCalledTimes ( 2 ) ;
302+ expect ( consoleSpy ) . toHaveBeenCalledWith ( '[Sentry] Could not upload sourcemaps' , uploadError ) ;
303+ expect ( rm ) . toHaveBeenCalledTimes ( 2 ) ;
290304 } ) ;
291305
292306 it ( 'should log debug information when debug is enabled' , async ( ) => {
0 commit comments