|
| 1 | +import * as fs from 'fs'; |
| 2 | +import * as os from 'os'; |
| 3 | +import * as path from 'path'; |
| 4 | +import * as esbuild from 'esbuild'; |
| 5 | +import type { BuildOptions } from 'esbuild'; |
| 6 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| 7 | +import { sentryEsbuildPlugin } from '../../src/esbuild'; |
| 8 | +import type { Options } from '../../src/core'; |
| 9 | + |
| 10 | +const { mockSourcemapUpload } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn() })); |
| 11 | + |
| 12 | +vi.mock('sentry', () => ({ |
| 13 | + createSentrySDK: () => ({ |
| 14 | + release: { create: vi.fn(), finalize: vi.fn(), 'set-commits': vi.fn(), deploy: vi.fn() }, |
| 15 | + sourcemap: { upload: mockSourcemapUpload, inject: vi.fn() }, |
| 16 | + run: vi.fn(), |
| 17 | + }), |
| 18 | +})); |
| 19 | + |
| 20 | +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; |
| 21 | + |
| 22 | +const credentials: Options = { |
| 23 | + authToken: 't', |
| 24 | + org: 'o', |
| 25 | + project: 'p', |
| 26 | + release: { name: 'test-release', create: false, finalize: false }, |
| 27 | + telemetry: false, |
| 28 | + silent: true, |
| 29 | +}; |
| 30 | + |
| 31 | +type Upload = { directory: string; files: Record<string, string> }; |
| 32 | + |
| 33 | +describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { |
| 34 | + let tmpDir: string; |
| 35 | + let entry: string; |
| 36 | + let outDir: string; |
| 37 | + let uploads: Upload[]; |
| 38 | + |
| 39 | + beforeEach(() => { |
| 40 | + vi.clearAllMocks(); |
| 41 | + uploads = []; |
| 42 | + mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => { |
| 43 | + const files: Record<string, string> = {}; |
| 44 | + for (const file of fs.readdirSync(directory)) { |
| 45 | + files[file] = fs.readFileSync(path.join(directory, file), 'utf8'); |
| 46 | + } |
| 47 | + uploads.push({ directory, files }); |
| 48 | + }); |
| 49 | + |
| 50 | + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-esbuild-delete-maps-')); |
| 51 | + entry = path.join(tmpDir, 'entry.js'); |
| 52 | + outDir = path.join(tmpDir, 'dist'); |
| 53 | + fs.writeFileSync(entry, 'export const answer = 42;\nconsole.log(answer);\n'); |
| 54 | + }); |
| 55 | + |
| 56 | + afterEach(() => { |
| 57 | + vi.restoreAllMocks(); |
| 58 | + fs.rmSync(tmpDir, { recursive: true, force: true }); |
| 59 | + }); |
| 60 | + |
| 61 | + function build(pluginOptions: Options = {}, overrides: BuildOptions = {}) { |
| 62 | + return esbuild.build({ |
| 63 | + entryPoints: [entry], |
| 64 | + bundle: true, |
| 65 | + outdir: outDir, |
| 66 | + absWorkingDir: tmpDir, |
| 67 | + plugins: [sentryEsbuildPlugin({ ...credentials, ...pluginOptions })], |
| 68 | + ...overrides, |
| 69 | + }); |
| 70 | + } |
| 71 | + |
| 72 | + function expectUploadedPair(upload: Upload, bundle: string): void { |
| 73 | + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; |
| 74 | + expect(debugId).toBeDefined(); |
| 75 | + expect(Object.keys(upload.files).sort()).toEqual([`${debugId}-0.js`, `${debugId}-0.js.map`]); |
| 76 | + expect(JSON.parse(upload.files[`${debugId}-0.js.map`]!)).toMatchObject({ debug_id: debugId }); |
| 77 | + expect(fs.existsSync(upload.directory)).toBe(false); |
| 78 | + } |
| 79 | + |
| 80 | + it('enables external source maps, uploads them and deletes them when the setting is unset', async () => { |
| 81 | + await build(); |
| 82 | + |
| 83 | + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); |
| 84 | + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); |
| 85 | + expect(bundle).not.toContain('sourceMappingURL'); |
| 86 | + |
| 87 | + expect(uploads).toHaveLength(1); |
| 88 | + expectUploadedPair(uploads[0]!, bundle); |
| 89 | + }); |
| 90 | + |
| 91 | + it('switches linked source maps to external and deletes them when the option is true', async () => { |
| 92 | + await build({ sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: true }); |
| 93 | + |
| 94 | + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); |
| 95 | + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).not.toContain('sourceMappingURL'); |
| 96 | + expect(uploads).toHaveLength(1); |
| 97 | + }); |
| 98 | + |
| 99 | + it('keeps external source maps the user enabled', async () => { |
| 100 | + await build({}, { sourcemap: 'external' }); |
| 101 | + |
| 102 | + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); |
| 103 | + expect(uploads).toHaveLength(1); |
| 104 | + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); |
| 105 | + }); |
| 106 | + |
| 107 | + it('keeps linked source maps when the option is unset', async () => { |
| 108 | + await build({}, { sourcemap: true }); |
| 109 | + |
| 110 | + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); |
| 111 | + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).toContain('//# sourceMappingURL=entry.js.map'); |
| 112 | + }); |
| 113 | + |
| 114 | + it('does not enable source maps when the option is false', async () => { |
| 115 | + await build({ sourcemaps: { deleteSourcemapsAfterUpload: false } }); |
| 116 | + |
| 117 | + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); |
| 118 | + expect(uploads).toHaveLength(0); |
| 119 | + }); |
| 120 | + |
| 121 | + it('warns when source maps are turned off', async () => { |
| 122 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); |
| 123 | + await build({ silent: false }, { sourcemap: false }); |
| 124 | + |
| 125 | + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); |
| 126 | + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source map generation is turned off')); |
| 127 | + }); |
| 128 | + |
| 129 | + it('warns when source maps are inlined and keeps them', async () => { |
| 130 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); |
| 131 | + await build({ silent: false, sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: 'inline' }); |
| 132 | + |
| 133 | + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).toContain('sourceMappingURL=data:'); |
| 134 | + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source maps are inlined')); |
| 135 | + }); |
| 136 | + |
| 137 | + it('does not enable source maps when no upload can run', async () => { |
| 138 | + await build({ authToken: undefined }); |
| 139 | + |
| 140 | + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); |
| 141 | + expect(uploads).toHaveLength(0); |
| 142 | + }); |
| 143 | + |
| 144 | + it('does not delete anything when the build does not write', async () => { |
| 145 | + const rmSpy = vi.spyOn(fs.promises, 'rm'); |
| 146 | + const result = await build({}, { write: false }); |
| 147 | + |
| 148 | + expect(result.outputFiles?.map(file => path.basename(file.path)).sort()).toEqual(['entry.js', 'entry.js.map']); |
| 149 | + expect(rmSpy).not.toHaveBeenCalledWith(expect.stringContaining('entry.js.map'), expect.anything()); |
| 150 | + }); |
| 151 | + |
| 152 | + it('keeps the source maps when the upload fails', async () => { |
| 153 | + mockSourcemapUpload.mockRejectedValue(new Error('upload failed')); |
| 154 | + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); |
| 155 | + await build({ silent: false }); |
| 156 | + |
| 157 | + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); |
| 158 | + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('were not uploaded')); |
| 159 | + }); |
| 160 | + |
| 161 | + it('leaves "disable-upload" builds untouched', async () => { |
| 162 | + await build({ sourcemaps: { disable: 'disable-upload' } }, { sourcemap: true }); |
| 163 | + |
| 164 | + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); |
| 165 | + expect(uploads).toHaveLength(0); |
| 166 | + }); |
| 167 | +}); |
0 commit comments