From 7ff0cfe12c2d7605b5600abaa66f2d390b98f53a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 19 Oct 2026 17:24:29 +0200 Subject: [PATCH] fix(core): allow project arrays in framework build options Co-Authored-By: GPT-6 --- packages/astro/test/buildOptions.test-d.ts | 2 +- .../buildTimeOptionsBase.ts | 4 +- .../nuxt/test/vite/buildOptions.test-d.ts | 2 +- .../src/vite/buildEnd/handleOnBuildEnd.ts | 26 ++- .../vite/buildEnd/handleOnBuildEnd.test.ts | 154 +++++++++++++++++- .../react-router/test/vite/types.test-d.ts | 2 +- 6 files changed, 170 insertions(+), 20 deletions(-) diff --git a/packages/astro/test/buildOptions.test-d.ts b/packages/astro/test/buildOptions.test-d.ts index 275e47ae68ee..da853204f179 100644 --- a/packages/astro/test/buildOptions.test-d.ts +++ b/packages/astro/test/buildOptions.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry Astro build-time options type', () => { const completeOptions: SentryOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' }, diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 36a8967c8105..9cc883e8e257 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -30,9 +30,11 @@ export interface BuildTimeOptionsBase { /** * The slug of the Sentry project associated with the app. * + * Multiple projects can be passed to upload the build's source maps to each of them. + * * This value can also be specified via the `SENTRY_PROJECT` environment variable. */ - project?: string; + project?: string | string[]; /** * The authentication token to use for all communication with Sentry. diff --git a/packages/nuxt/test/vite/buildOptions.test-d.ts b/packages/nuxt/test/vite/buildOptions.test-d.ts index 95fa09f09778..a1f6df85ec52 100644 --- a/packages/nuxt/test/vite/buildOptions.test-d.ts +++ b/packages/nuxt/test/vite/buildOptions.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry Nuxt build-time options type', () => { const completeOptions: SentryNuxtModuleOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' }, diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 3af80b186e14..ffd02ac3ca45 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -58,7 +58,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo token: authToken, org, url: sentryUrl, - project, + project: Array.isArray(project) ? undefined : project, headers, }; @@ -68,7 +68,10 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo if (release?.name) { try { // The release API requires a project, and the one given to the SDK does not reach this call. - await sentry.release.create({ orgVersion: release.name, project }); + await sentry.release.create({ + orgVersion: release.name, + project: Array.isArray(project) ? project.join(',') : project, + }); } catch (error) { // eslint-disable-next-line no-console console.error('[Sentry] Could not create release', error); @@ -79,6 +82,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo // uploaded manually at a later point - only `true` turns source maps off entirely. const sourceMapsFullyDisabled = sourcemaps?.disable === true; const uploadDisabled = sourceMapsFullyDisabled || sourcemaps?.disable === 'disable-upload'; + let sourceMapsUploaded = false; if (!sourceMapsFullyDisabled && viteConfig.build.sourcemap !== false) { // inject debugIds @@ -92,10 +96,18 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo if (!uploadDisabled) { // upload sourcemaps try { - await sentry.sourcemap.upload({ - directory: reactRouterConfig.buildDirectory, - release: release?.name || 'undefined', - }); + const uploadClients = Array.isArray(project) + ? project.map(project => createSentrySDK({ ...sentryOptions, project })) + : [sentry]; + + for (const client of uploadClients) { + await client.sourcemap.upload({ + directory: reactRouterConfig.buildDirectory, + release: release?.name || 'undefined', + }); + } + + sourceMapsUploaded = uploadClients.length > 0; } catch (error) { // eslint-disable-next-line no-console console.error('[Sentry] Could not upload sourcemaps', error); @@ -105,7 +117,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo // Only clean up source maps that were actually uploaded. Deleting them after skipping // the upload would leave the user with neither, breaking a manual upload. - if (uploadDisabled) { + if (!sourceMapsUploaded) { return; } diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index 9bb18672ba3c..c0f525b72258 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -1,5 +1,5 @@ import { createSentrySDK } from 'sentry'; -import * as fs from 'fs'; +import { rm } from 'node:fs/promises'; import { glob } from 'glob'; import type { ResolvedConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -7,11 +7,10 @@ import { sentryOnBuildEnd } from '../../../src/vite/buildEnd/handleOnBuildEnd'; import type { SentryReactRouterBuildOptions } from '../../../src/vite/types'; vi.mock('sentry'); -vi.mock('fs', () => ({ - promises: { - rm: vi.fn().mockResolvedValue(undefined), - }, -})); +vi.mock('node:fs/promises', () => { + const rm = vi.fn().mockResolvedValue(undefined); + return { rm, default: { rm } }; +}); vi.mock('glob'); type TestConfig = ResolvedConfig & { @@ -59,12 +58,16 @@ describe('sentryOnBuildEnd', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(createSentrySDK).mockReturnValue(mockSentrySdkInstance as unknown as ReturnType); + mockSentrySdkInstance.sourcemap.upload.mockReset(); + vi.mocked(createSentrySDK) + .mockReset() + .mockReturnValue(mockSentrySdkInstance as unknown as ReturnType); vi.mocked(glob).mockResolvedValue(['/build/file1.map', '/build/file2.map']); - vi.mocked(fs.promises.rm).mockResolvedValue(undefined); + vi.mocked(rm).mockResolvedValue(undefined); }); afterEach(() => { + vi.restoreAllMocks(); vi.resetModules(); }); @@ -140,6 +143,139 @@ describe('sentryOnBuildEnd', () => { }); }); + it('uploads to each project with one debug ID injection and cleanup after both uploads', async () => { + const frontendUpload = vi.fn(); + const backendUpload = vi.fn(); + vi.mocked(createSentrySDK) + .mockReturnValueOnce(mockSentrySdkInstance as unknown as ReturnType) + .mockReturnValueOnce({ + ...mockSentrySdkInstance, + sourcemap: { ...mockSentrySdkInstance.sourcemap, upload: frontendUpload }, + } as unknown as ReturnType) + .mockReturnValueOnce({ + ...mockSentrySdkInstance, + sourcemap: { ...mockSentrySdkInstance.sourcemap, upload: backendUpload }, + } as unknown as ReturnType); + const sentryConfig: SentryReactRouterBuildOptions = { + ...defaultConfig.viteConfig.sentryConfig, + project: ['frontend', 'backend'], + release: { name: 'v1.0.0' }, + sentryUrl: 'https://sentry.example.com', + headers: { 'X-Custom-Header': 'test-value' }, + }; + const config = { + ...defaultConfig, + viteConfig: { ...defaultConfig.viteConfig, sentryConfig }, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(createSentrySDK).toHaveBeenCalledTimes(3); + expect(createSentrySDK).toHaveBeenNthCalledWith(1, { + token: 'test-token', + org: 'test-org', + url: 'https://sentry.example.com', + headers: { 'X-Custom-Header': 'test-value' }, + project: undefined, + }); + expect(createSentrySDK).toHaveBeenNthCalledWith(2, { + token: 'test-token', + org: 'test-org', + url: 'https://sentry.example.com', + headers: { 'X-Custom-Header': 'test-value' }, + project: 'frontend', + }); + expect(createSentrySDK).toHaveBeenNthCalledWith(3, { + token: 'test-token', + org: 'test-org', + url: 'https://sentry.example.com', + headers: { 'X-Custom-Header': 'test-value' }, + project: 'backend', + }); + expect(mockSentrySdkInstance.release.create).toHaveBeenCalledExactlyOnceWith({ + orgVersion: 'v1.0.0', + project: 'frontend,backend', + }); + expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledExactlyOnceWith({ directory: '/build' }); + expect(frontendUpload).toHaveBeenCalledExactlyOnceWith({ directory: '/build', release: 'v1.0.0' }); + expect(backendUpload).toHaveBeenCalledExactlyOnceWith({ directory: '/build', release: 'v1.0.0' }); + expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledBefore(frontendUpload); + expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledBefore(backendUpload); + expect(glob).toHaveBeenCalledExactlyOnceWith(['/build/**/*.map'], { absolute: true, nodir: true }); + expect(rm).toHaveBeenCalledTimes(2); + expect(rm).toHaveBeenCalledWith('/build/file1.map', { force: true }); + expect(rm).toHaveBeenCalledWith('/build/file2.map', { force: true }); + expect(frontendUpload).toHaveBeenCalledBefore(vi.mocked(rm)); + expect(backendUpload).toHaveBeenCalledBefore(vi.mocked(rm)); + }); + + it('preserves source maps when one project upload fails', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const uploadError = new Error('Backend upload failed'); + mockSentrySdkInstance.sourcemap.upload.mockResolvedValueOnce(undefined).mockRejectedValueOnce(uploadError); + const sentryConfig: SentryReactRouterBuildOptions = { + ...defaultConfig.viteConfig.sentryConfig, + project: ['frontend', 'backend'], + }; + const config = { + ...defaultConfig, + viteConfig: { ...defaultConfig.viteConfig, sentryConfig }, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentrySdkInstance.sourcemap.upload).toHaveBeenCalledTimes(2); + expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', uploadError); + expect(glob).not.toHaveBeenCalled(); + expect(rm).not.toHaveBeenCalled(); + }); + + it('preserves source maps when no projects are configured in the array', async () => { + const sentryConfig: SentryReactRouterBuildOptions = { + ...defaultConfig.viteConfig.sentryConfig, + project: [], + }; + const config = { + ...defaultConfig, + viteConfig: { ...defaultConfig.viteConfig, sentryConfig }, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled(); + expect(glob).not.toHaveBeenCalled(); + expect(rm).not.toHaveBeenCalled(); + }); + + it('leaves project resolution to the CLI when the project option is omitted', async () => { + const sentryConfig: SentryReactRouterBuildOptions = { + ...defaultConfig.viteConfig.sentryConfig, + project: undefined, + }; + const config = { + ...defaultConfig, + viteConfig: { ...defaultConfig.viteConfig, sentryConfig }, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(createSentrySDK).toHaveBeenCalledExactlyOnceWith({ + token: 'test-token', + org: 'test-org', + url: undefined, + headers: undefined, + project: undefined, + }); + expect(mockSentrySdkInstance.sourcemap.upload).toHaveBeenCalledExactlyOnceWith({ + directory: '/build', + release: 'undefined', + }); + }); + it('should not upload or inject source maps when disabled via top-level sourcemaps.disable', async () => { const config = { ...defaultConfig, @@ -198,7 +334,7 @@ describe('sentryOnBuildEnd', () => { await sentryOnBuildEnd(config); expect(glob).not.toHaveBeenCalled(); - expect(fs.promises.rm).not.toHaveBeenCalled(); + expect(rm).not.toHaveBeenCalled(); }); it('should delete source maps after upload with default pattern', async () => { diff --git a/packages/react-router/test/vite/types.test-d.ts b/packages/react-router/test/vite/types.test-d.ts index f7ba9b49b8b9..699aa1323521 100644 --- a/packages/react-router/test/vite/types.test-d.ts +++ b/packages/react-router/test/vite/types.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry React-Router build-time options type', () => { const completeOptions: SentryReactRouterBuildOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' },