|
| 1 | +import * as assert from 'assert/strict'; |
| 2 | +import * as fs from 'fs'; |
| 3 | +import * as path from 'path'; |
| 4 | +import { |
| 5 | + findDebugIdsWithConflictingSourcemaps, |
| 6 | + findFilesWithForeignSourcemaps, |
| 7 | + findFilesWithMultipleDebugIds, |
| 8 | + findInjectedDebugIds, |
| 9 | + findSourceMapFiles, |
| 10 | + findSourceMappingUrlComments, |
| 11 | + getArtifactBundles, |
| 12 | + getAssembleRequests, |
| 13 | + getChunkUploadPosts, |
| 14 | + getDebugIdPairs, |
| 15 | + getSourcemaps, |
| 16 | + loadMockServerResults, |
| 17 | + startNodeServer, |
| 18 | +} from '@sentry-internal/test-utils'; |
| 19 | + |
| 20 | +/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ |
| 21 | +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; |
| 22 | + |
| 23 | +/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ |
| 24 | +const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; |
| 25 | + |
| 26 | +/** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */ |
| 27 | +const CLIENT_OUTPUT = path.join('.output', 'public'); |
| 28 | +const SERVER_OUTPUT = path.join('.output', 'server'); |
| 29 | + |
| 30 | +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ |
| 31 | +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; |
| 32 | +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; |
| 33 | + |
| 34 | +const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; |
| 35 | + |
| 36 | +function filesContaining(dir: string, needle: string): string[] { |
| 37 | + return fs |
| 38 | + .readdirSync(dir, { recursive: true, withFileTypes: true }) |
| 39 | + .filter(entry => entry.isFile()) |
| 40 | + .map(entry => path.join(entry.parentPath, entry.name)) |
| 41 | + .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. |
| 46 | + * Such a chunk still gets a debug ID, but there is no map to upload for it. |
| 47 | + */ |
| 48 | +function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> { |
| 49 | + const debugIds = new Set<string>(); |
| 50 | + |
| 51 | + for (const file of filesContaining(dir, 'sentry-dbid-')) { |
| 52 | + if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { |
| 53 | + continue; |
| 54 | + } |
| 55 | + |
| 56 | + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { |
| 57 | + debugIds.add((match[1] as string).toLowerCase()); |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + return debugIds; |
| 62 | +} |
| 63 | + |
| 64 | +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob |
| 65 | +// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. |
| 66 | +function debugIdsOfFilesWithGlobCharacters(dir: string): Set<string> { |
| 67 | + const debugIds = new Set<string>(); |
| 68 | + |
| 69 | + for (const file of filesContaining(dir, 'sentry-dbid-')) { |
| 70 | + if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { |
| 71 | + continue; |
| 72 | + } |
| 73 | + |
| 74 | + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { |
| 75 | + debugIds.add((match[1] as string).toLowerCase()); |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + return debugIds; |
| 80 | +} |
| 81 | + |
| 82 | +console.log( |
| 83 | + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + |
| 84 | + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, |
| 85 | +); |
| 86 | + |
| 87 | +const requests = loadMockServerResults(); |
| 88 | + |
| 89 | +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); |
| 90 | +for (const request of requests) { |
| 91 | + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); |
| 92 | +} |
| 93 | +console.log(''); |
| 94 | + |
| 95 | +// --- The upload reached Sentry --- |
| 96 | + |
| 97 | +assert.ok( |
| 98 | + requests.some(r => r.authorization.includes('fake-auth-token')), |
| 99 | + 'Expected requests with the configured auth token', |
| 100 | +); |
| 101 | + |
| 102 | +// Sentry rejects a release that names no project with a 400, and the mock server accepts anything, |
| 103 | +// so assert on the body rather than on the call alone. |
| 104 | +const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); |
| 105 | +assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); |
| 106 | +for (const request of releaseCreates) { |
| 107 | + const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; |
| 108 | + assert.equal( |
| 109 | + body?.version, |
| 110 | + 'test-release', |
| 111 | + `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, |
| 112 | + ); |
| 113 | + assert.deepEqual( |
| 114 | + body?.projects, |
| 115 | + ['test-project'], |
| 116 | + `Expected the release create body to name the project, got ${JSON.stringify(body)}`, |
| 117 | + ); |
| 118 | +} |
| 119 | + |
| 120 | +assert.ok( |
| 121 | + requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), |
| 122 | + 'Expected a PUT to finalize the release', |
| 123 | +); |
| 124 | + |
| 125 | +assert.ok( |
| 126 | + requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), |
| 127 | + 'Expected a GET for the chunk-upload options', |
| 128 | +); |
| 129 | + |
| 130 | +const chunkPosts = getChunkUploadPosts(requests); |
| 131 | +assert.ok( |
| 132 | + chunkPosts.some(r => r.bodySize > 0), |
| 133 | + 'Expected at least one chunk upload POST with a non-empty body', |
| 134 | +); |
| 135 | + |
| 136 | +const assembleRequests = getAssembleRequests(requests); |
| 137 | +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); |
| 138 | +for (const request of assembleRequests) { |
| 139 | + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); |
| 140 | + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); |
| 141 | + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); |
| 142 | + for (const chunk of request.assembleBody?.chunks ?? []) { |
| 143 | + assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +const bundles = getArtifactBundles(requests); |
| 148 | +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); |
| 149 | +console.log(`Found ${bundles.length} artifact bundle(s)\n`); |
| 150 | + |
| 151 | +// --- Both bundlers uploaded --- |
| 152 | + |
| 153 | +const sourcemaps = getSourcemaps(bundles); |
| 154 | +assert.ok( |
| 155 | + sourcemaps.some(map => map.sourcemap.mappings?.length), |
| 156 | + 'Expected at least one uploaded sourcemap with non-empty mappings', |
| 157 | +); |
| 158 | + |
| 159 | +const containsMarker = (marker: string): boolean => |
| 160 | + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); |
| 161 | + |
| 162 | +// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either |
| 163 | +// plugin dropped, so each side is pinned to a marker only that side's source supplies. |
| 164 | +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); |
| 165 | +// TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the |
| 166 | +// module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed: |
| 167 | +// assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); |
| 168 | + |
| 169 | +// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. |
| 170 | +const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( |
| 171 | + source => source.startsWith('../') || path.isAbsolute(source), |
| 172 | +); |
| 173 | +assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); |
| 174 | + |
| 175 | +// --- Debug IDs tie the shipped bundle to the uploaded map --- |
| 176 | + |
| 177 | +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); |
| 178 | +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); |
| 179 | + |
| 180 | +const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); |
| 181 | +assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); |
| 182 | + |
| 183 | +// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting |
| 184 | +// the upload alone cannot show this. |
| 185 | +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk |
| 186 | +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the |
| 187 | +// maps are kept. The upload is the same in all variants. |
| 188 | +const outputDirsWithSourceMaps = [ |
| 189 | + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), |
| 190 | + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), |
| 191 | +]; |
| 192 | +const debugIdsWithoutSourceMap = new Set( |
| 193 | + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), |
| 194 | +); |
| 195 | +const debugIdsSkippedByUploader = isStaticBuild ? new Set<string>() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); |
| 196 | + |
| 197 | +for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { |
| 198 | + const injectedDebugIds = findInjectedDebugIds({ outputDir }); |
| 199 | + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); |
| 200 | + // The exemption for chunks without a source map must not let a build without any uploaded map pass. |
| 201 | + assert.ok( |
| 202 | + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), |
| 203 | + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, |
| 204 | + ); |
| 205 | + |
| 206 | + if (outputDirsWithSourceMaps.includes(outputDir)) { |
| 207 | + const unuploaded = injectedDebugIds.filter( |
| 208 | + debugId => |
| 209 | + !uploadedDebugIds.has(debugId) && |
| 210 | + !debugIdsWithoutSourceMap.has(debugId) && |
| 211 | + !debugIdsSkippedByUploader.has(debugId), |
| 212 | + ); |
| 213 | + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); |
| 214 | + } |
| 215 | + |
| 216 | + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); |
| 217 | + |
| 218 | + // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. |
| 219 | + // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 |
| 220 | + const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); |
| 221 | + assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); |
| 222 | + |
| 223 | + // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on |
| 224 | + // Vite's map even if Nitro uploaded nothing. |
| 225 | + const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); |
| 226 | + assert.deepEqual( |
| 227 | + filesWithForeignSourcemaps, |
| 228 | + [], |
| 229 | + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, |
| 230 | + ); |
| 231 | +} |
| 232 | + |
| 233 | +// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. |
| 234 | +const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); |
| 235 | +assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); |
| 236 | + |
| 237 | +// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output |
| 238 | +// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach |
| 239 | +// `.vue` files. Assert this when the server maps reach the original sources: |
| 240 | +// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); |
| 241 | +// const serverSources = sourcemaps |
| 242 | +// .filter(map => map.debugId && serverDebugIds.has(map.debugId)) |
| 243 | +// .flatMap(map => getSourcemapSources(map.sourcemap)); |
| 244 | +// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); |
| 245 | +console.log(''); |
| 246 | + |
| 247 | +// --- What the build leaves behind in the client output --- |
| 248 | + |
| 249 | +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); |
| 250 | + |
| 251 | +if (keepClientSourceMaps) { |
| 252 | + assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); |
| 253 | + console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`); |
| 254 | +} else { |
| 255 | + // This directory is served to the internet, so a surviving `.map` hands out the original source. |
| 256 | + assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); |
| 257 | + |
| 258 | + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. |
| 259 | + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); |
| 260 | + assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); |
| 261 | + |
| 262 | + // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. |
| 263 | + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); |
| 264 | + assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); |
| 265 | + |
| 266 | + console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); |
| 267 | +} |
| 268 | + |
| 269 | +// --- What the built server answers for the client source maps --- |
| 270 | + |
| 271 | +// `nuxt generate` emits no server, and a static host serves only the files that exist. |
| 272 | +if (!isStaticBuild) { |
| 273 | + const clientChunkUrls = fs |
| 274 | + .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) |
| 275 | + .filter(entry => entry.isFile() && entry.name.endsWith('.js')) |
| 276 | + .map( |
| 277 | + entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, |
| 278 | + ); |
| 279 | + assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); |
| 280 | + |
| 281 | + const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); |
| 282 | + |
| 283 | + try { |
| 284 | + // Without this, a server that serves no public files at all would pass the 404 checks. |
| 285 | + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; |
| 286 | + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); |
| 287 | + |
| 288 | + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that |
| 289 | + // stays on the list, and Nitro answers its request with a 500 instead of a 404. |
| 290 | + const responses = await Promise.all( |
| 291 | + clientChunkUrls.map(async chunkUrl => { |
| 292 | + const mapUrl = `${chunkUrl}.map`; |
| 293 | + const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; |
| 294 | + const { status } = await fetch(`${server.baseUrl}${mapUrl}`); |
| 295 | + return { mapUrl, status, expectedStatus }; |
| 296 | + }), |
| 297 | + ); |
| 298 | + const unexpectedStatuses = responses |
| 299 | + .filter(({ status, expectedStatus }) => status !== expectedStatus) |
| 300 | + .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); |
| 301 | + assert.deepEqual( |
| 302 | + unexpectedStatuses, |
| 303 | + [], |
| 304 | + 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', |
| 305 | + ); |
| 306 | + |
| 307 | + console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); |
| 308 | + } finally { |
| 309 | + await server.close(); |
| 310 | + } |
| 311 | +} |
| 312 | + |
| 313 | +console.log('All sourcemap assertions passed!'); |
0 commit comments