Skip to content

Commit 974f0cb

Browse files
committed
add on build end hook
1 parent 5164de0 commit 974f0cb

File tree

3 files changed

+298
-0
lines changed

3 files changed

+298
-0
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { Config } from '@react-router/dev/dist/config';
2+
import SentryCli from '@sentry/cli';
3+
import glob from 'glob';
4+
import * as fs from 'fs';
5+
6+
type ExtendedBuildEndArgs = Parameters<NonNullable<Config['buildEnd']>>[0] & {
7+
sentryConfig: {
8+
authToken?: string;
9+
org?: string;
10+
project?: string;
11+
sourceMapsUploadOptions?: {
12+
enabled?: boolean;
13+
filesToDeleteAfterUpload?: string | string[] | false;
14+
};
15+
release?: {
16+
name?: string;
17+
};
18+
debug?: boolean;
19+
};
20+
};
21+
22+
type ExtendedBuildEndHook = (args: ExtendedBuildEndArgs) => void | Promise<void>;
23+
24+
/**
25+
* A build end hook that handles Sentry release creation and source map uploads.
26+
* It creates a new Sentry release if configured, uploads source maps to Sentry,
27+
* and optionally deletes the source map files after upload.
28+
*/
29+
export const sentryOnBuildEnd: ExtendedBuildEndHook = async ({ reactRouterConfig, viteConfig, sentryConfig }) => {
30+
const { authToken, org, project, release, sourceMapsUploadOptions, debug } = sentryConfig;
31+
const cliInstance = new SentryCli(null, {
32+
authToken,
33+
org,
34+
project,
35+
});
36+
37+
// check if release should be created
38+
if (release?.name) {
39+
try {
40+
await cliInstance.releases.new(release.name);
41+
} catch (error) {
42+
// eslint-disable-next-line no-console
43+
console.error('[Sentry] Could not create release', error);
44+
}
45+
}
46+
47+
// upload sourcemaps
48+
if (sourceMapsUploadOptions?.enabled ?? (true && viteConfig.build.sourcemap !== false)) {
49+
try {
50+
await cliInstance.releases.uploadSourceMaps(release?.name || 'undefined', {
51+
include: [
52+
{
53+
paths: [reactRouterConfig.buildDirectory],
54+
},
55+
],
56+
});
57+
} catch (error) {
58+
// eslint-disable-next-line no-console
59+
console.error('[Sentry] Could not upload sourcemaps', error);
60+
}
61+
}
62+
63+
// delete sourcemaps after upload
64+
let updatedFilesToDeleteAfterUpload = sourceMapsUploadOptions?.filesToDeleteAfterUpload;
65+
66+
// set a default value no option was set
67+
if (typeof sourceMapsUploadOptions?.filesToDeleteAfterUpload === 'undefined') {
68+
updatedFilesToDeleteAfterUpload = [`${reactRouterConfig.buildDirectory}/**/*.map`];
69+
70+
if (debug) {
71+
// eslint-disable-next-line no-console
72+
console.info(
73+
`[Sentry] Automatically setting \`sourceMapsUploadOptions.filesToDeleteAfterUpload: ${JSON.stringify(
74+
updatedFilesToDeleteAfterUpload,
75+
)}\` to delete generated source maps after they were uploaded to Sentry.`,
76+
);
77+
}
78+
}
79+
80+
if (updatedFilesToDeleteAfterUpload) {
81+
try {
82+
const filePathsToDelete = await glob(updatedFilesToDeleteAfterUpload, {
83+
absolute: true,
84+
nodir: true,
85+
});
86+
87+
if (debug) {
88+
filePathsToDelete.forEach(filePathToDelete => {
89+
// eslint-disable-next-line no-console
90+
console.info(`Deleting asset after upload: ${filePathToDelete}`);
91+
});
92+
}
93+
94+
await Promise.all(
95+
filePathsToDelete.map(filePathToDelete =>
96+
fs.promises.rm(filePathToDelete, { force: true }).catch((e: unknown) => {
97+
if (debug) {
98+
// This is allowed to fail - we just don't do anything
99+
// eslint-disable-next-line no-console
100+
console.debug(`An error occurred while attempting to delete asset: ${filePathToDelete}`, e);
101+
}
102+
}),
103+
),
104+
);
105+
} catch (error) {
106+
// eslint-disable-next-line no-console
107+
console.error('Error deleting files after sourcemap upload:', error);
108+
}
109+
}
110+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { sentryReactRouter } from './plugin';
2+
export { sentryOnBuildEnd } from './buildEnd/handleOnBuildEnd';
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { sentryOnBuildEnd } from '../../../src/vite/buildEnd/handleOnBuildEnd';
3+
import SentryCli from '@sentry/cli';
4+
import * as fs from 'fs';
5+
import glob from 'glob';
6+
import type { ResolvedConfig } from 'vite';
7+
8+
// Mock dependencies
9+
vi.mock('@sentry/cli');
10+
vi.mock('fs');
11+
vi.mock('glob');
12+
13+
describe('sentryOnBuildEnd', () => {
14+
const mockSentryCliInstance = {
15+
releases: {
16+
new: vi.fn(),
17+
uploadSourceMaps: vi.fn(),
18+
},
19+
};
20+
21+
const defaultConfig = {
22+
buildManifest: undefined,
23+
reactRouterConfig: {
24+
appDirectory: '/app',
25+
basename: '/',
26+
buildDirectory: '/build',
27+
future: {
28+
unstable_optimizeDeps: false,
29+
},
30+
prerender: undefined,
31+
routes: {},
32+
serverBuildFile: 'server.js',
33+
serverModuleFormat: 'esm' as const,
34+
ssr: true,
35+
},
36+
viteConfig: {
37+
build: {
38+
sourcemap: true,
39+
},
40+
} as ResolvedConfig,
41+
sentryConfig: {
42+
authToken: 'test-token',
43+
org: 'test-org',
44+
project: 'test-project',
45+
debug: false,
46+
},
47+
};
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
// @ts-expect-error - mocking constructor
52+
SentryCli.mockImplementation(() => mockSentryCliInstance);
53+
vi.mocked(glob).mockResolvedValue(['/build/file1.map', '/build/file2.map']);
54+
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
55+
});
56+
57+
afterEach(() => {
58+
vi.resetModules();
59+
});
60+
61+
it('should create a new Sentry release when release name is provided', async () => {
62+
const config = {
63+
...defaultConfig,
64+
sentryConfig: {
65+
...defaultConfig.sentryConfig,
66+
release: {
67+
name: 'v1.0.0',
68+
},
69+
},
70+
};
71+
72+
await sentryOnBuildEnd(config);
73+
74+
expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0');
75+
});
76+
77+
it('should upload source maps when enabled', async () => {
78+
const config = {
79+
...defaultConfig,
80+
sentryConfig: {
81+
...defaultConfig.sentryConfig,
82+
sourceMapsUploadOptions: {
83+
enabled: true,
84+
},
85+
},
86+
};
87+
88+
await sentryOnBuildEnd(config);
89+
90+
expect(mockSentryCliInstance.releases.uploadSourceMaps).toHaveBeenCalledWith('undefined', {
91+
include: [{ paths: ['/build'] }],
92+
});
93+
});
94+
95+
it('should not upload source maps when explicitly disabled', async () => {
96+
const config = {
97+
...defaultConfig,
98+
sentryConfig: {
99+
...defaultConfig.sentryConfig,
100+
sourceMapsUploadOptions: {
101+
enabled: false,
102+
},
103+
},
104+
};
105+
106+
await sentryOnBuildEnd(config);
107+
108+
expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled();
109+
});
110+
111+
it('should delete source maps after upload with default pattern', async () => {
112+
await sentryOnBuildEnd(defaultConfig);
113+
114+
expect(glob).toHaveBeenCalledWith(['/build/**/*.map'], {
115+
absolute: true,
116+
nodir: true,
117+
});
118+
expect(fs.promises.rm).toHaveBeenCalledTimes(2);
119+
});
120+
121+
it('should delete custom files after upload when specified', async () => {
122+
const config = {
123+
...defaultConfig,
124+
sentryConfig: {
125+
...defaultConfig.sentryConfig,
126+
sourceMapsUploadOptions: {
127+
filesToDeleteAfterUpload: '/custom/**/*.map',
128+
},
129+
},
130+
};
131+
132+
await sentryOnBuildEnd(config);
133+
134+
expect(glob).toHaveBeenCalledWith('/custom/**/*.map', {
135+
absolute: true,
136+
nodir: true,
137+
});
138+
});
139+
140+
it('should handle errors during release creation gracefully', async () => {
141+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
142+
mockSentryCliInstance.releases.new.mockRejectedValueOnce(new Error('Release creation failed'));
143+
144+
const config = {
145+
...defaultConfig,
146+
sentryConfig: {
147+
...defaultConfig.sentryConfig,
148+
release: {
149+
name: 'v1.0.0',
150+
},
151+
},
152+
};
153+
154+
await sentryOnBuildEnd(config);
155+
156+
expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not create release', expect.any(Error));
157+
consoleSpy.mockRestore();
158+
});
159+
160+
it('should handle errors during source map upload gracefully', async () => {
161+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
162+
mockSentryCliInstance.releases.uploadSourceMaps.mockRejectedValueOnce(new Error('Upload failed'));
163+
164+
await sentryOnBuildEnd(defaultConfig);
165+
166+
expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', expect.any(Error));
167+
consoleSpy.mockRestore();
168+
});
169+
170+
it('should log debug information when debug is enabled', async () => {
171+
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
172+
173+
const config = {
174+
...defaultConfig,
175+
sentryConfig: {
176+
...defaultConfig.sentryConfig,
177+
debug: true,
178+
},
179+
};
180+
181+
await sentryOnBuildEnd(config);
182+
183+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Sentry] Automatically setting'));
184+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleting asset after upload:'));
185+
consoleSpy.mockRestore();
186+
});
187+
});

0 commit comments

Comments
 (0)