From 9f3f5d6a198553953dd72cde253151e68d291779 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 22 Oct 2026 02:12:00 +0100 Subject: [PATCH] test(e2e): Assert source map upload and stripping with a shared helper Adds `assertSourceMapUpload` to test-utils and uses it in the Astro, SvelteKit, Remix and Nuxt source map apps. The SolidStart 1, SolidStart 2 and TanStack Start apps now build against the mock Sentry server and run the same assertions, so the source map handling of every framework SDK that strips maps itself is covered before the bundler plugins take it over. --- .../astro-7-sourcemaps/assert-build.ts | 102 ++-------- .../nuxt-4-sourcemaps/assert-build.ts | 92 ++------- .../nuxt-5-sourcemaps/assert-build.ts | 131 ++++-------- .../remix-sourcemaps/app/entry.client.tsx | 1 + .../app/lib/greeting.server.ts | 4 + .../remix-sourcemaps/app/routes/_index.tsx | 11 +- .../remix-sourcemaps/assert-build.ts | 123 +++--------- .../solidstart-2/assert-build.ts | 24 +++ .../solidstart-2/package.json | 4 +- .../solidstart-2/src/entry-client.tsx | 1 + .../solidstart-2/src/entry-server.tsx | 1 + .../solidstart-2/start-mock-sentry-server.mjs | 3 + .../solidstart-2/vite.config.ts | 30 ++- .../solidstart/app.config.ts | 16 +- .../solidstart/assert-build.ts | 16 ++ .../test-applications/solidstart/package.json | 4 +- .../solidstart/src/entry-client.tsx | 1 + .../solidstart/src/entry-server.tsx | 1 + .../solidstart/start-mock-sentry-server.mjs | 4 + .../sveltekit-3-sourcemaps/assert-build.ts | 90 ++------- .../tanstackstart-react/assert-build.ts | 16 ++ .../tanstackstart-react/package.json | 4 +- .../tanstackstart-react/src/client.tsx | 1 + .../tanstackstart-react/src/server.ts | 1 + .../start-mock-sentry-server.mjs | 3 + .../tanstackstart-react/vite.config.ts | 30 ++- dev-packages/test-utils/src/build-output.ts | 27 +++ dev-packages/test-utils/src/index.ts | 3 + .../src/sourcemap-upload-assertions.ts | 186 ++++++++++++++++++ 29 files changed, 475 insertions(+), 455 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs create mode 100644 dev-packages/test-utils/src/sourcemap-upload-assertions.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts index aeb76ee19e32..ad96fabd3f14 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts @@ -1,97 +1,23 @@ -import * as assert from 'node:assert/strict'; -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - findInjectedDebugIds, - findSourceMapFiles, - getArtifactBundles, - getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, -} from '@sentry-internal/test-utils'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; const BUILD_OUTPUT = 'dist'; const OUTPUT_DIRS = isStatic ? [BUILD_OUTPUT] : [path.join(BUILD_OUTPUT, 'client'), path.join(BUILD_OUTPUT, 'server')]; -/** All markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ -const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; -const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; -const PRERENDER_MARKER = 'SOURCEMAP_MARKER_PRERENDER'; - -/** Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. */ -const debugIdsWithoutSourceMap = new Set( - fs.existsSync('.tmp_debug_ids_without_source_map') - ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) - : [], -); - -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); - -assert.ok( - getChunkUploadPosts(requests).some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); -} - -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); - -const sourcemaps = getSourcemaps(bundles); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); -if (!isStatic) { - assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); -} -// Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips -assert.ok(containsMarker(PRERENDER_MARKER), 'Expected an uploaded sourcemap carrying the prerendered page source'); - -const buildOutput = ['.tmp_build_stdout', '.tmp_build_stderr'] - .filter(file => fs.existsSync(file)) - .map(file => fs.readFileSync(file, 'utf8')) - .join('\n'); -assert.ok( - !buildOutput.includes("Didn't find any matching sources for debug ID upload"), - 'Expected every Vite build to find sources for the debug ID upload', -); - -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - -for (const outputDir of OUTPUT_DIRS) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - - const unuploaded = injectedDebugIds.filter( - debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); -} - -// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. -assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); +assertSourceMapUpload({ + outputDirs: OUTPUT_DIRS, + // The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. + strippedDirs: [BUILD_OUTPUT], + markers: { + 'client source': 'SOURCEMAP_MARKER_CLIENT', + ...(isStatic ? {} : { 'server source': 'SOURCEMAP_MARKER_SERVER' }), + // Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips + 'prerendered page source': 'SOURCEMAP_MARKER_PRERENDER', + }, + // Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 8a9ee339b1e6..782b54789526 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -2,19 +2,15 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; import { + assertSourceMapUpload, findDebugIdsWithConflictingSourcemaps, findFilesWithForeignSourcemaps, findFilesWithMultipleDebugIds, findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments, - getArtifactBundles, getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, getSourcemapSources, - getSourcemaps, - loadMockServerResults, startNodeServer, } from '@sentry-internal/test-utils'; @@ -86,20 +82,22 @@ console.log( `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -// --- The upload reached Sentry --- +const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT], + // The client output is checked below, because the "kept" variant expects its maps to stay and some presets + // write the server into it. + strippedDirs: [], + // Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass + // with either plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the + // only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + // Nitro copies Vite's client output to `CLIENT_OUTPUT`, and Sentry deletes no maps in Vite's own directory. + debugIdsWithoutSourceMap: debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT), +}); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); +// --- Nuxt-specific checks on the upload --- // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, // so assert on the body rather than on the call alone. @@ -129,79 +127,23 @@ assert.ok( 'Expected a GET for the chunk-upload options', ); -const chunkPosts = getChunkUploadPosts(requests); -assert.ok( - chunkPosts.some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +for (const request of getAssembleRequests(requests)) { assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); for (const chunk of request.assembleBody?.chunks ?? []) { assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); } } -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Both bundlers uploaded --- - -const sourcemaps = getSourcemaps(bundles); -assert.ok( - sourcemaps.some(map => map.sourcemap.mappings?.length), - 'Expected at least one uploaded sourcemap with non-empty mappings', -); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -// Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass -// with either plugin dropped, so each side is pinned to a marker only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); -// Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the -// only reason this marker survives into `sourcesContent`. -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); - // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( source => source.startsWith('../') || path.isAbsolute(source), ); assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// --- Debug IDs tie the shipped bundle to the uploaded map --- - -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting -// the upload alone cannot show this. -const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT); - for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - // The exemption for chunks without a source map must not let a build without any uploaded map pass. - assert.ok( - injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), - `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, - ); - - const unuploaded = injectedDebugIds.filter( - debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); - - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); - // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts index 0a86f401f470..8c64d688098f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -2,18 +2,15 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; import { + assertSourceMapUpload, + findDebugIdsInFilesWithGlobCharacters, findDebugIdsWithConflictingSourcemaps, findFilesWithForeignSourcemaps, findFilesWithMultipleDebugIds, findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments, - getArtifactBundles, getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, startNodeServer, } from '@sentry-internal/test-utils'; @@ -61,43 +58,39 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { return debugIds; } -// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob -// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. -function debugIdsOfFilesWithGlobCharacters(dir: string): Set { - const debugIds = new Set(); - - for (const file of filesContaining(dir, 'sentry-dbid-')) { - if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { - continue; - } - - for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { - debugIds.add((match[1] as string).toLowerCase()); - } - } - - return debugIds; -} - console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -// --- The upload reached Sentry --- - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the +// maps are kept. The upload is the same in all variants. +const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), +]; +const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), ); +const debugIdsSkippedByUploader = isStaticBuild + ? [] + : findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT }); + +const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: outputDirsWithSourceMaps, + // The client output is checked below, because the "kept" variant expects its maps to stay. + strippedDirs: [], + // Vite builds the client and Nitro builds the server. Counting bundles would still pass with either + // plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module + // sets it to `false`, which is the only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + debugIdsWithoutSourceMap: [...debugIdsWithoutSourceMap, ...debugIdsSkippedByUploader], +}); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Nuxt-specific checks on the upload --- // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, // so assert on the body rather than on the call alone. @@ -127,92 +120,34 @@ assert.ok( 'Expected a GET for the chunk-upload options', ); -const chunkPosts = getChunkUploadPosts(requests); -assert.ok( - chunkPosts.some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +for (const request of getAssembleRequests(requests)) { assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); for (const chunk of request.assembleBody?.chunks ?? []) { assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); } } -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Both bundlers uploaded --- - -const sourcemaps = getSourcemaps(bundles); -assert.ok( - sourcemaps.some(map => map.sourcemap.mappings?.length), - 'Expected at least one uploaded sourcemap with non-empty mappings', -); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either -// plugin dropped, so each side is pinned to a marker only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); -// Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module -// sets it to `false`, which is the only reason this marker survives into `sourcesContent`. -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); - // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( source => source.startsWith('../') || path.isAbsolute(source), ); assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// --- Debug IDs tie the shipped bundle to the uploaded map --- - -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting -// the upload alone cannot show this. -// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk -// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the -// maps are kept. The upload is the same in all variants. -const outputDirsWithSourceMaps = [ - ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), - ...(isStaticBuild ? [] : [SERVER_OUTPUT]), -]; -const debugIdsWithoutSourceMap = new Set( - outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), -); -const debugIdsSkippedByUploader = isStaticBuild ? new Set() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); +// --- Every output directory ships at least one uploaded debug ID --- +// The shared assertions cover the directories that keep their maps. A directory whose maps are deleted +// must still ship a debug ID with an uploaded map, or a build that uploads nothing would pass. for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - // The exemption for chunks without a source map must not let a build without any uploaded map pass. assert.ok( injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, ); - if (outputDirsWithSourceMaps.includes(outputDir)) { - const unuploaded = injectedDebugIds.filter( - debugId => - !uploadedDebugIds.has(debugId) && - !debugIdsWithoutSourceMap.has(debugId) && - !debugIdsSkippedByUploader.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); - } - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx index 9a290b88c9a4..1f65a0f9be7b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT import { RemixBrowser } from '@remix-run/react'; import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts new file mode 100644 index 000000000000..02c2ba99b548 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_SERVER +export function getServerGreeting(): string { + return 'Hello from the server'; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx index ea184ec80a18..f2c03a8c2682 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx @@ -1,3 +1,12 @@ +import { json } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { getServerGreeting } from '../lib/greeting.server'; + +export function loader() { + return json({ greeting: getServerGreeting() }); +} + export default function Index() { - return

Sourcemaps test app

; + const { greeting } = useLoaderData(); + return

Sourcemaps test app: {greeting}

; } diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts index 7f4ae319867b..64ee74111b0f 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts @@ -1,10 +1,12 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; -import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; const BUILD_DIR = 'build'; -const CLIENT_ASSETS_DIR = 'build/client/assets'; +const CLIENT_OUTPUT = path.join(BUILD_DIR, 'client'); +const SERVER_OUTPUT = path.join(BUILD_DIR, 'server'); +const CLIENT_ASSETS_DIR = path.join(CLIENT_OUTPUT, 'assets'); // Both injectors write this assignment, so counting it per file counts injections regardless of // which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss @@ -12,117 +14,38 @@ const CLIENT_ASSETS_DIR = 'build/client/assets'; const DEBUG_ID_ASSIGNMENT = /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi; -function getClientChunks(): string[] { - assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); +assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); - return fs - .readdirSync(CLIENT_ASSETS_DIR) - .filter(file => file.endsWith('.js')) - .map(file => path.join(CLIENT_ASSETS_DIR, file)); -} - -const chunks = getClientChunks(); +const chunks = fs + .readdirSync(CLIENT_ASSETS_DIR) + .filter(file => file.endsWith('.js')) + .map(file => path.join(CLIENT_ASSETS_DIR, file)); assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`); -// 1. No chunk carries more than one debug ID. +// No chunk carries more than one debug ID. // // Two injections per chunk is the failure mode of // https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime, // `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens // when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite // plugin, so the CLI injects a second ID over the plugin's. -const injectedDebugIds = new Map(); - for (const chunk of chunks) { - const code = fs.readFileSync(chunk, 'utf-8'); - const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); + const ids = [...fs.readFileSync(chunk, 'utf-8').matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); assert.ok( ids.length <= 1, `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`, ); - - if (ids.length === 1) { - injectedDebugIds.set(chunk, ids[0] as string); - } } - -console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`); - -const requests = loadMockServerResults(); -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle'); - -// 2. Source maps with real content reached Sentry. -// -// Asserting on the upload rather than on disk, because deleting the maps after a successful upload -// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not -// configure source maps itself. -const uploadedSourcemaps = getSourcemaps(bundles); -assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles'); -assert.ok( - uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0), - 'Expected at least one uploaded source map with non-empty mappings', -); -console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`); - -// 3. The debug IDs that shipped are the ones that were uploaded. -// -// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that -// has no artifact bundle behind it. -const debugIdPairs = getDebugIdPairs(bundles); -const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID'); - -// The uploaded artifacts are named after the debug ID (`~/-.js`), not after the chunk -// they came from, so the two file name sets never line up. Cross-check the IDs themselves: every -// debug ID that shipped has to have an artifact bundle behind it. -let crossCheckedChunks = 0; - -for (const [chunk, injectedDebugId] of injectedDebugIds) { - assert.ok( - uploadedDebugIds.has(injectedDebugId.toLowerCase()), - `Debug ID ${injectedDebugId} in ${chunk} was never uploaded.\n` + - `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}`, - ); - crossCheckedChunks++; -} - -assert.ok( - crossCheckedChunks > 0, - 'Expected at least one chunk carrying a debug ID to cross-check against the upload.\n' + - `Client chunks: ${JSON.stringify(chunks.map(chunk => path.basename(chunk)))}\n` + - `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}\n` + - `Uploaded JS urls: ${JSON.stringify(debugIdPairs.map(pair => pair.jsUrl))}`, -); -console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`); - -// 4. No source map survived the build. -// -// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting, so -// a leftover `.map` means one of the builds was never cleaned up. Remix runs a client and an SSR -// pass with different `outDir`s, while the deletion glob is held in a promise that settles once - -// this is what catches the second pass being left behind. -function findSourceMaps(dir: string): string[] { - return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { - const entryPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - return findSourceMaps(entryPath); - } - - return entry.name.endsWith('.map') ? [entryPath] : []; - }); -} - -const leftoverSourceMaps = findSourceMaps(BUILD_DIR); -assert.deepEqual( - leftoverSourceMaps, - [], - `Expected every source map to be deleted after upload, found ${leftoverSourceMaps.length}:\n${leftoverSourceMaps.join( - '\n', - )}`, -); -console.log('no source maps left in the build output\n'); - -console.log('All remix source map assertions passed!'); +console.log(`no client chunk carries more than one debug ID (${chunks.length} chunks)\n`); + +// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting. Remix +// runs a client and an SSR pass with different `outDir`s, while the deletion glob is held in a promise +// that settles once, so a leftover `.map` means the second pass was left behind. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [BUILD_DIR], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts new file mode 100644 index 000000000000..9cf7afcbe4ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { assertSourceMapUpload, findDebugIdsInFilesWithGlobCharacters } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// TODO: The client maps stay in `.output/public`, because the SDK's deletion glob `./**/*.map` skips dot +// folders. Add `CLIENT_OUTPUT` to `strippedDirs` once the SDK deletes them. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [SERVER_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, + debugIdsWithoutSourceMap: [ + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. + ...(fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : []), + ...findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT }), + ], +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 376bb2ef38fc..a8428951c236 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -4,12 +4,12 @@ "//": "Sentry is initialized from server/plugins/sentry.ts, a Nitro plugin. Build-time instrumentation means there is no `--import` preload and nothing to copy into .output.", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .nitro .output", - "build": "vite build", + "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", "preview": "HOST=localhost PORT=3030 vite preview", "start": "HOST=localhost PORT=3030 node .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "node assert-build.ts && pnpm test:prod" }, "type": "module", "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx index 11087fbb5918..17e38cf08b72 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // @refresh reload import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx index 276935366318..78f28e36a56c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER // @refresh reload import { StartServer, createHandler } from '@solidjs/start/server'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts index d3ffd97997a9..74811201d2cf 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts @@ -2,6 +2,27 @@ import { sentrySolidStart } from '@sentry/solidstart/vite'; import { solidStart } from '@solidjs/start/config'; import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; +import * as fs from 'node:fs'; + +// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + enforce: 'post' as const, + writeBundle(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }; +} export default defineConfig({ plugins: [ @@ -10,12 +31,15 @@ export default defineConfig({ middleware: './src/middleware.ts', }), sentrySolidStart({ - org: process.env.E2E_TEST_SENTRY_ORG_SLUG, - project: process.env.E2E_TEST_SENTRY_PROJECT, - authToken: process.env.E2E_TEST_AUTH_TOKEN, + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, debug: true, }), // `serverDir` defaults to `false`, which skips plugin scanning entirely. nitro({ serverDir: './server' }), + recordChunksWithoutSourceMap(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 71061cf25d96..336cfbac60a7 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -2,7 +2,17 @@ import { withSentry } from '@sentry/solidstart'; import { defineConfig } from '@solidjs/start/config'; export default defineConfig( - withSentry({ - middleware: './src/middleware.ts', - }), + withSentry( + { + middleware: './src/middleware.ts', + }, + { + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }, + ), ); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts b/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts new file mode 100644 index 000000000000..fd40cfc4de2a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// Vinxi writes the server output after the SDK deleted the maps, so `.output/server` keeps Nitro's own maps, +// which carry no mappings. They are not served, so only the client output must be free of maps. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 5567434a3c0e..eb837f408d61 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -3,13 +3,13 @@ "version": "0.0.0", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .vinxi .output", - "build": "vinxi build", + "build": "node start-mock-sentry-server.mjs & vinxi build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", "dev": "vinxi dev", "preview": "HOST=localhost PORT=3030 vinxi start", "start:import": "HOST=localhost PORT=3030 node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert": "node assert-build.ts && pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db" }, "type": "module", diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx index 11087fbb5918..17e38cf08b72 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // @refresh reload import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx index 276935366318..78f28e36a56c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER // @refresh reload import { StartServer, createHandler } from '@solidjs/start/server'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..d4bca7c2c259 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs @@ -0,0 +1,4 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +// Not a dot-folder: the SDK deletes `.*/**/*.map`, which would remove the maps the server extracts here. +startMockSentryServer({ outputDir: 'tmp_mock_chunks' }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts index 697c4437532d..7f70c46b9941 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts @@ -1,74 +1,29 @@ import * as assert from 'node:assert/strict'; -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - findInjectedDebugIds, - findSourceMapFiles, - findSourceMappingUrlComments, - getArtifactBundles, - getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, -} from '@sentry-internal/test-utils'; +import { assertSourceMapUpload, findSourceMapFiles } from '@sentry-internal/test-utils'; const BUILD_OUTPUT = 'build'; const CLIENT_OUTPUT = path.join(BUILD_OUTPUT, 'client'); const SERVER_OUTPUT = path.join(BUILD_OUTPUT, 'server'); -/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ -const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; -const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; +const { requests, sourcemaps } = assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + // The server output keeps `sourceMappingURL` comments that point at the deleted maps. Nothing serves + // it, so only the client output must be free of them. + strippedDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); -/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. */ -const debugIdsWithoutSourceMap = new Set( - fs.existsSync('.tmp_debug_ids_without_source_map') - ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) - : [], -); - -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); assert.ok( requests.some(r => r.url?.includes('/releases') && r.method === 'POST'), 'Expected a POST to create the release', ); -assert.ok( - getChunkUploadPosts(requests).some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); -} - -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); - -const sourcemaps = getSourcemaps(bundles); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); - // The auto-instrument plugin loads wrapped modules under this suffix. It must be stripped before // upload, or the uploaded sources don't match the frames Sentry receives. const wrappedSources = sourcemaps @@ -76,25 +31,4 @@ const wrappedSources = sourcemaps .filter(source => source.includes('?sentry-auto-wrap')); assert.deepEqual(wrappedSources, [], 'Expected no uploaded source to carry the auto-wrap query suffix'); -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - -for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - - const unuploaded = injectedDebugIds.filter( - debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); -} - -// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. -assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); -assert.deepEqual( - findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }), - [], - `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`, -); - console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts new file mode 100644 index 000000000000..422a947f537a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// The SDK enables them for the client build only: the server build in `.output/server` ships debug IDs but +// no source maps, so nothing of it reaches Sentry. Cover the server here once the SDK uploads its maps. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT' }, + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index ed739895d0d9..d2b3c156b829 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "build": "vite build && cp instrument.server.mjs .output/server", + "build": "node start-mock-sentry-server.mjs & (vite build && cp instrument.server.mjs .output/server) > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", "dev": "vite dev", "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test": "playwright test", @@ -17,7 +17,7 @@ "test:build:hoisted": "pnpm install --config.node-linker=hoisted && pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", - "test:assert": "pnpm test:assert:proxy", + "test:assert": "node assert-build.ts && pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx index fc489cd7bdeb..780672f38299 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // Imported first so Sentry.init() runs before any other import's side effects. import './instrument.client'; import './early-side-effect'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts index b10a3bc1e37b..f7376fa305e2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 2385d8aa5e93..f78b8666d702 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -1,10 +1,31 @@ import { defineConfig } from 'vite'; +import * as fs from 'node:fs'; import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + enforce: 'post' as const, + writeBundle(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }; +} + const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; const useManagedTunnelRoute = tunnelRouteMode !== 'off'; const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; @@ -50,11 +71,14 @@ export default defineConfig({ // react's vite plugin must come after start's vite plugin viteReact(), sentryTanstackStart({ - org: process.env.E2E_TEST_SENTRY_ORG_SLUG, - project: process.env.E2E_TEST_SENTRY_PROJECT, - authToken: process.env.E2E_TEST_AUTH_TOKEN, + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, debug: true, tunnelRoute, }), + recordChunksWithoutSourceMap(), ], }); diff --git a/dev-packages/test-utils/src/build-output.ts b/dev-packages/test-utils/src/build-output.ts index f42e5dd1bdb0..f174069321ce 100644 --- a/dev-packages/test-utils/src/build-output.ts +++ b/dev-packages/test-utils/src/build-output.ts @@ -37,6 +37,11 @@ function* walkFiles(outputDir: string, extensions?: string[]): Generator if (extensions && !extensions.includes(path.extname(entry.name))) { continue; } + // Dependencies a server build traces into its output are not the app's bundles. Some ship debug + // IDs of their own, for example the `sentry` package. + if (path.relative(outputDir, entry.parentPath).split(path.sep).includes('node_modules')) { + continue; + } yield path.join(entry.parentPath, entry.name); } } @@ -160,6 +165,28 @@ export function findInjectedDebugIds({ outputDir, extensions = JS_EXTENSIONS }: return [...debugIds]; } +/** + * Returns the debug IDs of emitted files whose path has glob characters, such as Nitro 3's + * `_libs/_sentry/node+[...].mjs`. + * + * TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips these files. + * Remove this when the uploader matches paths literally. + */ +export function findDebugIdsInFilesWithGlobCharacters({ + outputDir, + extensions = JS_EXTENSIONS, +}: OutputScanOptions): string[] { + const debugIds = new Set(); + + for (const file of walkFiles(outputDir, extensions)) { + if (/[[\]{}()*?]/.test(path.relative(outputDir, file))) { + readDebugIds(file).forEach(debugId => debugIds.add(debugId)); + } + } + + return [...debugIds]; +} + /** * Returns ` → ` for each file with more than one debug ID. * The uploader reads the first ID, but the SDK can report any of them. diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index bb70d9382f9e..6adabe0f44fb 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -23,6 +23,7 @@ export { findSourceMapFiles, findSourceMappingUrlComments, findInjectedDebugIds, + findDebugIdsInFilesWithGlobCharacters, findFilesWithMultipleDebugIds, findFilesWithForeignSourcemaps, bundleReferencesModule, @@ -44,6 +45,8 @@ export type { NodeServerOptions, NodeServer } from './node-server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; +export { assertSourceMapUpload } from './sourcemap-upload-assertions'; +export type { SourceMapUploadAssertionOptions, SourceMapUploadAssertionResult } from './sourcemap-upload-assertions'; export { CDPClient } from './cdp-client'; export type { CDPClientOptions } from './cdp-client'; diff --git a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts new file mode 100644 index 000000000000..8dc16efeb4ee --- /dev/null +++ b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts @@ -0,0 +1,186 @@ +/* oxlint-disable no-console -- the log is the context for a failed assertion */ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments } from './build-output'; +import type { ArtifactBundleData, RequestRecord, SourcemapEntry } from './sourcemap-upload-utils'; +import { + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from './sourcemap-upload-utils'; + +export interface SourceMapUploadAssertionOptions { + /** Directories with emitted JavaScript. Every debug ID injected there must have an uploaded source map. */ + outputDirs: string[]; + /** Directories that must hold no `.map` file and no `sourceMappingURL` comment. Defaults to `outputDirs`. */ + strippedDirs?: string[]; + /** + * Strings that an uploaded source map must carry in its `sourcesContent`, keyed by what they prove, for example + * `{ 'client source': 'SOURCEMAP_MARKER_CLIENT' }`. Put the marker in a comment, so the bundler strips it from + * the code but keeps it in the map. + */ + markers: Record; + /** Defaults to `fake-auth-token`. */ + authToken?: string; + /** Defaults to `test-project`. */ + project?: string; + /** Defaults to `test-release`. */ + release?: string; + /** + * Debug IDs of chunks without a source map, for which no upload is expected. Either the IDs themselves or a + * file with one ID per line. Defaults to `.tmp_debug_ids_without_source_map`, which a build plugin writes + * while the maps still exist, before the upload deletes them. A missing file means no exemptions. + */ + debugIdsWithoutSourceMap?: Iterable | string; + /** Build logs that must not carry the uploader's "no matching sources" warning. Missing files are skipped. */ + buildLogFiles?: string[]; + /** Defaults to the requests the mock Sentry server recorded. */ + requests?: RequestRecord[]; +} + +export interface SourceMapUploadAssertionResult { + requests: RequestRecord[]; + bundles: ArtifactBundleData[]; + sourcemaps: SourcemapEntry[]; + /** Lowercased debug IDs that have an uploaded JS and source map pair. */ + uploadedDebugIds: Set; +} + +function readDebugIdList(source: Iterable | string | undefined): Set { + if (source !== undefined && typeof source !== 'string') { + return new Set([...source].map(debugId => debugId.toLowerCase())); + } + + const file = source ?? '.tmp_debug_ids_without_source_map'; + if (!fs.existsSync(file)) { + return new Set(); + } + + return new Set( + fs + .readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map(debugId => debugId.toLowerCase()), + ); +} + +/** + * Asserts that a build uploaded its source maps to the mock Sentry server and stripped them from its output. + * + * This is the contract every framework SDK keeps when it manages source maps for the user: the maps reach + * Sentry with the debug IDs the shipped bundles carry, the shipped bundles reference no map, and no map + * is left where it would be served. + * + * Returns what it read, so an app can add assertions of its own on top. + */ +export function assertSourceMapUpload(options: SourceMapUploadAssertionOptions): SourceMapUploadAssertionResult { + const { + outputDirs, + strippedDirs = outputDirs, + markers, + authToken = 'fake-auth-token', + project = 'test-project', + release = 'test-release', + buildLogFiles = ['.tmp_build_stdout', '.tmp_build_stderr'], + } = options; + + const requests = options.requests ?? loadMockServerResults(); + + console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); + for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); + } + console.log(''); + + // --- The upload reached Sentry --- + + assert.ok( + requests.some(r => r.authorization.includes(authToken)), + 'Expected requests with the configured auth token', + ); + + assert.ok( + getChunkUploadPosts(requests).some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', + ); + + const assembleRequests = getAssembleRequests(requests); + assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); + for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes(project), `Expected assemble request for ${project}`); + assert.equal(request.assembleBody?.version, release, 'Expected assemble request to reference the release'); + } + + const bundles = getArtifactBundles(requests); + assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + + // --- The uploaded maps carry the app's source --- + + const sourcemaps = getSourcemaps(bundles); + assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded source map with non-empty mappings', + ); + + for (const [proves, marker] of Object.entries(markers)) { + assert.ok( + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))), + `Expected an uploaded source map carrying the ${proves} (marker ${marker})`, + ); + } + + const buildOutput = buildLogFiles + .filter(file => fs.existsSync(file)) + .map(file => fs.readFileSync(file, 'utf8')) + .join('\n'); + assert.ok( + !buildOutput.includes("Didn't find any matching sources for debug ID upload"), + 'Expected every build to find sources for the debug ID upload', + ); + + // --- Debug IDs tie the shipped bundles to the uploaded maps --- + + const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/source map pair with matching debug IDs'); + + const exemptDebugIds = readDebugIdList(options.debugIdsWithoutSourceMap); + + for (const outputDir of outputDirs) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded source map`, + ); + + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); + } + + // --- Nothing is left where it would be served --- + + for (const strippedDir of strippedDirs) { + // A surviving map hands out the original source to anyone who requests it. + assert.deepEqual(findSourceMapFiles({ outputDir: strippedDir }), [], `Expected no source maps in ${strippedDir}`); + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. + assert.deepEqual( + findSourceMappingUrlComments({ outputDir: strippedDir }), + [], + `Expected no sourceMappingURL comments in ${strippedDir}`, + ); + + console.log(` ${strippedDir}: free of source maps and sourceMappingURL comments`); + } + console.log(''); + + return { requests, bundles, sourcemaps, uploadedDebugIds }; +}